diff --git a/.mockery.yml b/.mockery.yml index 22cced8c..fba11d22 100644 --- a/.mockery.yml +++ b/.mockery.yml @@ -18,10 +18,18 @@ packages: config: all: true interfaces: + github.com/codesphere-cloud/oms/internal/installer: + config: + all: true + interfaces: github.com/codesphere-cloud/oms/internal/portal: config: all: true interfaces: + github.com/codesphere-cloud/oms/internal/system: + config: + all: true + interfaces: github.com/codesphere-cloud/oms/internal/util: config: all: true diff --git a/cli/cmd/build.go b/cli/cmd/build.go new file mode 100644 index 00000000..1bf87ca0 --- /dev/null +++ b/cli/cmd/build.go @@ -0,0 +1,28 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "github.com/codesphere-cloud/cs-go/pkg/io" + "github.com/spf13/cobra" +) + +// BuildCmd represents the build command +type BuildCmd struct { + cmd *cobra.Command +} + +func AddBuildCmd(rootCmd *cobra.Command, opts *GlobalOptions) { + build := BuildCmd{ + cmd: &cobra.Command{ + Use: "build", + Short: "Build and push images to a registry", + Long: io.Long(`Build and push container images to a registry using the provided configuration.`), + }, + } + AddBuildImagesCmd(build.cmd, opts) + AddBuildImageCmd(build.cmd, opts) + + rootCmd.AddCommand(build.cmd) +} diff --git a/cli/cmd/build_image.go b/cli/cmd/build_image.go new file mode 100644 index 00000000..c85ebecf --- /dev/null +++ b/cli/cmd/build_image.go @@ -0,0 +1,90 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "context" + "fmt" + "log" + + "github.com/codesphere-cloud/cs-go/pkg/io" + "github.com/codesphere-cloud/oms/internal/env" + "github.com/codesphere-cloud/oms/internal/installer" + "github.com/codesphere-cloud/oms/internal/system" + "github.com/codesphere-cloud/oms/internal/util" + "github.com/spf13/cobra" +) + +// BuildImageCmd represents the build image command +type BuildImageCmd struct { + cmd *cobra.Command + Opts BuildImageOpts + Env env.Env +} + +type BuildImageOpts struct { + *GlobalOptions + Dockerfile string + Package string + Registry string +} + +func (c *BuildImageCmd) RunE(cmd *cobra.Command, args []string) error { + pm := installer.NewPackage(c.Env.GetOmsWorkdir(), c.Opts.Package) + im := system.NewImage(context.Background()) + + return c.BuildImage(pm, im) +} + +func AddBuildImageCmd(parentCmd *cobra.Command, opts *GlobalOptions) { + imageCmd := &BuildImageCmd{ + cmd: &cobra.Command{ + Use: "image", + Short: "Build and push Docker image using Dockerfile and Codesphere package version", + Long: `Build a Docker image from a Dockerfile and push it to a registry, tagged with the Codesphere version from the package.`, + Example: formatExamplesWithBinary("build image", []io.Example{ + {Cmd: "--dockerfile baseimage/Dockerfile --package codesphere-v1.68.0.tar.gz --registry my-registry.com/my-image", Desc: "Build image for Codesphere version 1.68.0 and push to specified registry"}, + }, "oms-cli"), + Args: cobra.ExactArgs(0), + }, + Opts: BuildImageOpts{GlobalOptions: opts}, + Env: env.NewEnv(), + } + + imageCmd.cmd.Flags().StringVarP(&imageCmd.Opts.Dockerfile, "dockerfile", "d", "", "Path to the Dockerfile to build (required)") + imageCmd.cmd.Flags().StringVarP(&imageCmd.Opts.Package, "package", "p", "", "Path to the Codesphere package (required)") + imageCmd.cmd.Flags().StringVarP(&imageCmd.Opts.Registry, "registry", "r", "", "Registry URL to push to (e.g., my-registry.com/my-image) (required)") + + util.MarkFlagRequired(imageCmd.cmd, "dockerfile") + util.MarkFlagRequired(imageCmd.cmd, "package") + util.MarkFlagRequired(imageCmd.cmd, "registry") + + parentCmd.AddCommand(imageCmd.cmd) + + imageCmd.cmd.RunE = imageCmd.RunE +} + +// AddBuildImageCmd adds the build image command to the parent command +func (c *BuildImageCmd) BuildImage(pm installer.PackageManager, im system.ImageManager) error { + codesphereVersion, err := pm.GetCodesphereVersion() + if err != nil { + return fmt.Errorf("failed to get codesphere version from package: %w", err) + } + + targetImage := fmt.Sprintf("%s:%s", c.Opts.Registry, codesphereVersion) + + err = im.BuildImage(c.Opts.Dockerfile, targetImage, ".") + if err != nil { + return fmt.Errorf("failed to build image %s: %w", targetImage, err) + } + + err = im.PushImage(targetImage) + if err != nil { + return fmt.Errorf("failed to push image %s: %w", targetImage, err) + } + + log.Printf("Successfully built and pushed image: %s", targetImage) + + return nil +} diff --git a/cli/cmd/build_image_test.go b/cli/cmd/build_image_test.go new file mode 100644 index 00000000..b952a3a1 --- /dev/null +++ b/cli/cmd/build_image_test.go @@ -0,0 +1,170 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd_test + +import ( + "errors" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/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/system" +) + +var _ = Describe("BuildImageCmd", func() { + var ( + c cmd.BuildImageCmd + opts cmd.BuildImageOpts + globalOpts *cmd.GlobalOptions + mockEnv *env.MockEnv + ) + + BeforeEach(func() { + mockEnv = env.NewMockEnv(GinkgoT()) + globalOpts = &cmd.GlobalOptions{} + opts = cmd.BuildImageOpts{ + GlobalOptions: globalOpts, + Dockerfile: "Dockerfile", + Package: "codesphere-vcodesphere-v1.66.0.tar.gz", + Registry: "my-registry.com/my-image", + } + c = cmd.BuildImageCmd{ + Opts: opts, + Env: mockEnv, + } + }) + + AfterEach(func() { + mockEnv.AssertExpectations(GinkgoT()) + }) + + Context("RunE method", func() { + It("calls BuildImage and fails when package manager fails", func() { + mockEnv.EXPECT().GetOmsWorkdir().Return("/test/workdir") + + err := c.RunE(nil, []string{}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to get codesphere version from package")) + }) + + It("succeeds with valid options", func() { + mockEnv.EXPECT().GetOmsWorkdir().Return("/test/workdir") + + // This will fail in real scenario because it tries to extract from real package + // But it should at least call the correct methods + err := c.RunE(nil, []string{}) + Expect(err).To(HaveOccurred()) + }) + }) + + Context("BuildImage method", func() { + It("fails when package manager fails to get codesphere version", func() { + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) + mockImageManager := system.NewMockImageManager(GinkgoT()) + + mockPackageManager.EXPECT().GetCodesphereVersion().Return("", errors.New("failed to extract version")) + + err := c.BuildImage(mockPackageManager, mockImageManager) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to get codesphere version from package")) + }) + + It("fails when image build fails", func() { + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) + mockImageManager := system.NewMockImageManager(GinkgoT()) + + c.Opts.Dockerfile = "Dockerfile" + c.Opts.Registry = "my-registry.com/my-image" + + mockPackageManager.EXPECT().GetCodesphereVersion().Return("codesphere-v1.66.0", nil) + mockImageManager.EXPECT().BuildImage("Dockerfile", "my-registry.com/my-image:codesphere-v1.66.0", ".").Return(errors.New("build failed")) + + err := c.BuildImage(mockPackageManager, mockImageManager) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to build image")) + }) + + It("fails when image push fails", func() { + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) + mockImageManager := system.NewMockImageManager(GinkgoT()) + + c.Opts.Dockerfile = "Dockerfile" + c.Opts.Registry = "my-registry.com/my-image" + + mockPackageManager.EXPECT().GetCodesphereVersion().Return("codesphere-v1.66.0", nil) + mockImageManager.EXPECT().BuildImage("Dockerfile", "my-registry.com/my-image:codesphere-v1.66.0", ".").Return(nil) + mockImageManager.EXPECT().PushImage("my-registry.com/my-image:codesphere-v1.66.0").Return(errors.New("push failed")) + + err := c.BuildImage(mockPackageManager, mockImageManager) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to push image")) + }) + + It("successfully builds and pushes image", func() { + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) + mockImageManager := system.NewMockImageManager(GinkgoT()) + + c.Opts.Dockerfile = "Dockerfile" + c.Opts.Registry = "my-registry.com/my-image" + + mockPackageManager.EXPECT().GetCodesphereVersion().Return("codesphere-v1.66.0", nil) + mockImageManager.EXPECT().BuildImage("Dockerfile", "my-registry.com/my-image:codesphere-v1.66.0", ".").Return(nil) + mockImageManager.EXPECT().PushImage("my-registry.com/my-image:codesphere-v1.66.0").Return(nil) + + err := c.BuildImage(mockPackageManager, mockImageManager) + Expect(err).To(BeNil()) + }) + }) +}) + +var _ = Describe("AddBuildImageCmd", func() { + var ( + parentCmd *cobra.Command + globalOpts *cmd.GlobalOptions + ) + + BeforeEach(func() { + parentCmd = &cobra.Command{Use: "build"} + globalOpts = &cmd.GlobalOptions{} + }) + + It("adds the image command with correct properties and flags", func() { + cmd.AddBuildImageCmd(parentCmd, globalOpts) + + var imageCmd *cobra.Command + for _, c := range parentCmd.Commands() { + if c.Use == "image" { + imageCmd = c + break + } + } + + Expect(imageCmd).NotTo(BeNil()) + Expect(imageCmd.Use).To(Equal("image")) + Expect(imageCmd.Short).To(Equal("Build and push Docker image using Dockerfile and Codesphere package version")) + Expect(imageCmd.Long).To(ContainSubstring("Build a Docker image from a Dockerfile and push it to a registry")) + Expect(imageCmd.Long).To(ContainSubstring("tagged with the Codesphere version")) + Expect(imageCmd.RunE).NotTo(BeNil()) + + // Check required flags + dockerfileFlag := imageCmd.Flags().Lookup("dockerfile") + Expect(dockerfileFlag).NotTo(BeNil()) + Expect(dockerfileFlag.Shorthand).To(Equal("d")) + Expect(dockerfileFlag.Usage).To(ContainSubstring("Path to the Dockerfile to build")) + + packageFlag := imageCmd.Flags().Lookup("package") + Expect(packageFlag).NotTo(BeNil()) + Expect(packageFlag.Shorthand).To(Equal("p")) + Expect(packageFlag.Usage).To(ContainSubstring("Path to the Codesphere package")) + + registryFlag := imageCmd.Flags().Lookup("registry") + Expect(registryFlag).NotTo(BeNil()) + Expect(registryFlag.Shorthand).To(Equal("r")) + Expect(registryFlag.Usage).To(ContainSubstring("Registry URL to push to")) + }) +}) diff --git a/cli/cmd/build_images.go b/cli/cmd/build_images.go new file mode 100644 index 00000000..9cda37a4 --- /dev/null +++ b/cli/cmd/build_images.go @@ -0,0 +1,107 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "context" + "fmt" + "log" + + "github.com/codesphere-cloud/cs-go/pkg/io" + "github.com/codesphere-cloud/oms/internal/env" + "github.com/codesphere-cloud/oms/internal/installer" + "github.com/codesphere-cloud/oms/internal/system" + "github.com/codesphere-cloud/oms/internal/util" + "github.com/spf13/cobra" +) + +// BuildImagesCmd represents the build images command +type BuildImagesCmd struct { + cmd *cobra.Command + Opts *BuildImagesOpts + Env env.Env +} + +type BuildImagesOpts struct { + *GlobalOptions + Config string +} + +func (c *BuildImagesCmd) RunE(_ *cobra.Command, args []string) error { + pm := installer.NewPackage(c.Env.GetOmsWorkdir(), c.Opts.Config) + cm := installer.NewConfig() + im := system.NewImage(context.Background()) + + err := c.BuildAndPushImages(pm, cm, im) + if err != nil { + return fmt.Errorf("failed to build and push images: %w", err) + } + + return nil +} + +func AddBuildImagesCmd(build *cobra.Command, opts *GlobalOptions) { + buildImages := BuildImagesCmd{ + cmd: &cobra.Command{ + Use: "images", + Short: "Build and push container images", + Long: io.Long(`Build and push container images based on the configuration file. + Extracts necessary image configurations from the provided install config and the downloaded package.`), + }, + Opts: &BuildImagesOpts{GlobalOptions: opts}, + Env: env.NewEnv(), + } + buildImages.cmd.Flags().StringVarP(&buildImages.Opts.Config, "config", "c", "", "Path to the configuration YAML file") + + util.MarkFlagRequired(buildImages.cmd, "config") + + build.AddCommand(buildImages.cmd) + + buildImages.cmd.RunE = buildImages.RunE +} + +func (c *BuildImagesCmd) BuildAndPushImages(pm installer.PackageManager, cm installer.ConfigManager, im system.ImageManager) error { + config, err := cm.ParseConfigYaml(c.Opts.Config) + if err != nil { + return fmt.Errorf("failed to parse config: %w", err) + } + + if len(config.Codesphere.DeployConfig.Images) == 0 { + return fmt.Errorf("no images defined in the config") + } + if len(config.Registry.Server) == 0 { + return fmt.Errorf("registry server (property registry.server) not defined in the config, please specify a valid registry to which the image shall be pushed") + } + + codesphereVersion, err := pm.GetCodesphereVersion() + if err != nil { + return fmt.Errorf("failed to get codesphere version from package: %w", err) + } + + for imageName, imageConfig := range config.Codesphere.DeployConfig.Images { + for flavorName, flavorConfig := range imageConfig.Flavors { + log.Printf("Processing image '%s' with flavor '%s'", imageName, flavorName) + if flavorConfig.Image.Dockerfile == "" { + log.Printf("Skipping flavor '%s', no dockerfile defined", flavorName) + continue + } + + targetImage := fmt.Sprintf("%s/%s-%s:%s", config.Registry.Server, imageName, flavorName, codesphereVersion) + + err := im.BuildImage(flavorConfig.Image.Dockerfile, targetImage, ".") + if err != nil { + return fmt.Errorf("failed to build image %s: %w", targetImage, err) + } + + err = im.PushImage(targetImage) + if err != nil { + return fmt.Errorf("failed to push image %s: %w", targetImage, err) + } + + log.Printf("Successfully built and pushed image: %s", targetImage) + } + } + + return nil +} diff --git a/cli/cmd/build_images_test.go b/cli/cmd/build_images_test.go new file mode 100644 index 00000000..285762ed --- /dev/null +++ b/cli/cmd/build_images_test.go @@ -0,0 +1,423 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd_test + +import ( + "errors" + "os" + + . "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/installer/files" + "github.com/codesphere-cloud/oms/internal/system" +) + +const validConfigYaml = `registry: + server: "registry.example.com" +codesphere: + deployConfig: + images: + my-ubuntu-24.04: + name: "my-ubuntu-24.04" + supportedUntil: "2025-12-31" + flavors: + default: + image: + bomRef: "registry.example.com/my-ubuntu-24.04:latest" + dockerfile: "Dockerfile" + pool: + 1: 2 +` + +var _ = Describe("BuildImagesCmd", func() { + var ( + c cmd.BuildImagesCmd + opts *cmd.BuildImagesOpts + globalOpts *cmd.GlobalOptions + mockEnv *env.MockEnv + ) + + BeforeEach(func() { + mockEnv = env.NewMockEnv(GinkgoT()) + globalOpts = &cmd.GlobalOptions{} + opts = &cmd.BuildImagesOpts{ + GlobalOptions: globalOpts, + Config: "", + } + c = cmd.BuildImagesCmd{ + Opts: opts, + Env: mockEnv, + } + }) + + AfterEach(func() { + mockEnv.AssertExpectations(GinkgoT()) + }) + + Context("RunE method", func() { + It("calls BuildAndPushImages and fails on config parsing", func() { + mockEnv := env.NewMockEnv(GinkgoT()) + c.Env = mockEnv + c.Opts.Config = "non-existent-config.yaml" + + mockEnv.EXPECT().GetOmsWorkdir().Return("/test/workdir") + + err := c.RunE(nil, []string{}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to build and push images")) + }) + + It("succeeds with valid config and operations", func() { + mockEnv := env.NewMockEnv(GinkgoT()) + c.Env = mockEnv + + tempConfigFile, err := os.CreateTemp("", "test-config.yaml") + Expect(err).To(BeNil()) + defer func() { _ = os.Remove(tempConfigFile.Name()) }() + + _, err = tempConfigFile.WriteString(validConfigYaml) + Expect(err).To(BeNil()) + _ = tempConfigFile.Close() + + c.Opts.Config = tempConfigFile.Name() + + mockEnv.EXPECT().GetOmsWorkdir().Return("/test/workdir") + + err = c.RunE(nil, []string{}) + // This will fail because the dockerfile doesn't exist and build will fail + // But it should at least parse the config successfully + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to build and push images")) + }) + }) + + Context("BuildAndPushImages method", func() { + It("fails when config manager fails to parse config", func() { + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) + mockConfigManager := installer.NewMockConfigManager(GinkgoT()) + mockImageManager := system.NewMockImageManager(GinkgoT()) + + c.Opts.Config = "non-existent-config.yaml" + mockConfigManager.EXPECT().ParseConfigYaml("non-existent-config.yaml").Return(files.RootConfig{}, errors.New("failed to parse config")) + + err := c.BuildAndPushImages(mockPackageManager, mockConfigManager, mockImageManager) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to parse config")) + }) + + It("fails when no images are defined in config", func() { + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) + mockConfigManager := installer.NewMockConfigManager(GinkgoT()) + mockImageManager := system.NewMockImageManager(GinkgoT()) + + c.Opts.Config = "empty-config.yaml" + emptyConfig := files.RootConfig{ + Codesphere: files.CodesphereConfig{ + DeployConfig: files.DeployConfig{ + Images: map[string]files.ImageConfig{}, + }, + }, + } + mockConfigManager.EXPECT().ParseConfigYaml("empty-config.yaml").Return(emptyConfig, nil) + + err := c.BuildAndPushImages(mockPackageManager, mockConfigManager, mockImageManager) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("no images defined in the config")) + }) + + It("fails when registry server is empty", func() { + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) + mockConfigManager := installer.NewMockConfigManager(GinkgoT()) + mockImageManager := system.NewMockImageManager(GinkgoT()) + + c.Opts.Config = "config-without-registry.yaml" + configWithoutRegistry := files.RootConfig{ + Registry: files.RegistryConfig{ + // Empty server + }, + Codesphere: files.CodesphereConfig{ + DeployConfig: files.DeployConfig{ + Images: map[string]files.ImageConfig{ + "my-ubuntu-24.04": { + Name: "my-ubuntu-24.04", + SupportedUntil: "2025-12-31", + Flavors: map[string]files.FlavorConfig{ + "default": { + Image: files.ImageRef{ + BomRef: "my-ubuntu-24.04:latest", + Dockerfile: "Dockerfile", + }, + }, + }, + }, + }, + }, + }, + } + mockConfigManager.EXPECT().ParseConfigYaml("config-without-registry.yaml").Return(configWithoutRegistry, nil) + + err := c.BuildAndPushImages(mockPackageManager, mockConfigManager, mockImageManager) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("registry server (property registry.server) not defined in the config")) + }) + + It("skips flavors without dockerfile", func() { + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) + mockConfigManager := installer.NewMockConfigManager(GinkgoT()) + mockImageManager := system.NewMockImageManager(GinkgoT()) + + c.Opts.Config = "config-without-dockerfile.yaml" + configWithoutDockerfile := files.RootConfig{ + Registry: files.RegistryConfig{ + Server: "registry.example.com", + }, + Codesphere: files.CodesphereConfig{ + DeployConfig: files.DeployConfig{ + Images: map[string]files.ImageConfig{ + "my-ubuntu-24.04": { + Name: "my-ubuntu-24.04", + SupportedUntil: "2025-12-31", + Flavors: map[string]files.FlavorConfig{ + "default": { + Image: files.ImageRef{ + BomRef: "registry.example.com/my-ubuntu-24.04:latest", + // No dockerfile specified + }, + }, + }, + }, + }, + }, + }, + } + mockConfigManager.EXPECT().ParseConfigYaml("config-without-dockerfile.yaml").Return(configWithoutDockerfile, nil) + mockPackageManager.EXPECT().GetCodesphereVersion().Return("1.0.0", nil) + + err := c.BuildAndPushImages(mockPackageManager, mockConfigManager, mockImageManager) + Expect(err).To(BeNil()) + }) + + It("fails when image build fails", func() { + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) + mockConfigManager := installer.NewMockConfigManager(GinkgoT()) + mockImageManager := system.NewMockImageManager(GinkgoT()) + + c.Opts.Config = "config-with-dockerfile.yaml" + configWithDockerfile := files.RootConfig{ + Registry: files.RegistryConfig{ + Server: "registry.example.com", + }, + Codesphere: files.CodesphereConfig{ + DeployConfig: files.DeployConfig{ + Images: map[string]files.ImageConfig{ + "my-ubuntu-24.04": { + Name: "my-ubuntu-24.04", + SupportedUntil: "2025-12-31", + Flavors: map[string]files.FlavorConfig{ + "default": { + Image: files.ImageRef{ + BomRef: "registry.example.com/my-ubuntu-24.04:latest", + Dockerfile: "Dockerfile", + }, + }, + }, + }, + }, + }, + }, + } + mockConfigManager.EXPECT().ParseConfigYaml("config-with-dockerfile.yaml").Return(configWithDockerfile, nil) + mockPackageManager.EXPECT().GetCodesphereVersion().Return("1.0.0", nil) + mockImageManager.EXPECT().BuildImage("Dockerfile", "registry.example.com/my-ubuntu-24.04-default:1.0.0", ".").Return(errors.New("build failed")) + + err := c.BuildAndPushImages(mockPackageManager, mockConfigManager, mockImageManager) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to build image")) + }) + + It("fails when image push fails", func() { + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) + mockConfigManager := installer.NewMockConfigManager(GinkgoT()) + mockImageManager := system.NewMockImageManager(GinkgoT()) + + c.Opts.Config = "config-with-dockerfile.yaml" + configWithDockerfile := files.RootConfig{ + Registry: files.RegistryConfig{ + Server: "registry.example.com", + }, + Codesphere: files.CodesphereConfig{ + DeployConfig: files.DeployConfig{ + Images: map[string]files.ImageConfig{ + "my-ubuntu-24.04": { + Name: "my-ubuntu-24.04", + SupportedUntil: "2025-12-31", + Flavors: map[string]files.FlavorConfig{ + "default": { + Image: files.ImageRef{ + BomRef: "registry.example.com/my-ubuntu-24.04:latest", + Dockerfile: "Dockerfile", + }, + }, + }, + }, + }, + }, + }, + } + mockConfigManager.EXPECT().ParseConfigYaml("config-with-dockerfile.yaml").Return(configWithDockerfile, nil) + mockPackageManager.EXPECT().GetCodesphereVersion().Return("1.0.0", nil) + mockImageManager.EXPECT().BuildImage("Dockerfile", "registry.example.com/my-ubuntu-24.04-default:1.0.0", ".").Return(nil) + mockImageManager.EXPECT().PushImage("registry.example.com/my-ubuntu-24.04-default:1.0.0").Return(errors.New("push failed")) + + err := c.BuildAndPushImages(mockPackageManager, mockConfigManager, mockImageManager) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to push image")) + }) + + It("successfully builds and pushes single image", func() { + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) + mockConfigManager := installer.NewMockConfigManager(GinkgoT()) + mockImageManager := system.NewMockImageManager(GinkgoT()) + + c.Opts.Config = "config-with-dockerfile.yaml" + configWithDockerfile := files.RootConfig{ + Registry: files.RegistryConfig{ + Server: "registry.example.com", + }, + Codesphere: files.CodesphereConfig{ + DeployConfig: files.DeployConfig{ + Images: map[string]files.ImageConfig{ + "my-ubuntu-24.04": { + Name: "my-ubuntu-24.04", + SupportedUntil: "2025-12-31", + Flavors: map[string]files.FlavorConfig{ + "default": { + Image: files.ImageRef{ + BomRef: "registry.example.com/my-ubuntu-24.04:latest", + Dockerfile: "Dockerfile", + }, + }, + }, + }, + }, + }, + }, + } + mockConfigManager.EXPECT().ParseConfigYaml("config-with-dockerfile.yaml").Return(configWithDockerfile, nil) + mockPackageManager.EXPECT().GetCodesphereVersion().Return("1.0.0", nil) + mockImageManager.EXPECT().BuildImage("Dockerfile", "registry.example.com/my-ubuntu-24.04-default:1.0.0", ".").Return(nil) + mockImageManager.EXPECT().PushImage("registry.example.com/my-ubuntu-24.04-default:1.0.0").Return(nil) + + err := c.BuildAndPushImages(mockPackageManager, mockConfigManager, mockImageManager) + Expect(err).To(BeNil()) + }) + + It("successfully builds and pushes multiple images with different flavors", func() { + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) + mockConfigManager := installer.NewMockConfigManager(GinkgoT()) + mockImageManager := system.NewMockImageManager(GinkgoT()) + + c.Opts.Config = "config-with-multiple-images.yaml" + configWithMultipleImages := files.RootConfig{ + Registry: files.RegistryConfig{ + Server: "registry.example.com", + }, + Codesphere: files.CodesphereConfig{ + DeployConfig: files.DeployConfig{ + Images: map[string]files.ImageConfig{ + "my-ubuntu-24.04": { + Name: "my-ubuntu-24.04", + SupportedUntil: "2025-12-31", + Flavors: map[string]files.FlavorConfig{ + "default": { + Image: files.ImageRef{ + BomRef: "registry.example.com/my-ubuntu-24.04:latest", + Dockerfile: "Dockerfile.default", + }, + }, + "minimal": { + Image: files.ImageRef{ + BomRef: "registry.example.com/my-ubuntu-24.04:minimal", + Dockerfile: "Dockerfile.minimal", + }, + }, + }, + }, + "my-alpine-3.18": { + Name: "my-alpine-3.18", + SupportedUntil: "2025-12-31", + Flavors: map[string]files.FlavorConfig{ + "default": { + Image: files.ImageRef{ + BomRef: "registry.example.com/my-alpine-3.18:latest", + Dockerfile: "Dockerfile.alpine", + }, + }, + }, + }, + }, + }, + }, + } + mockConfigManager.EXPECT().ParseConfigYaml("config-with-multiple-images.yaml").Return(configWithMultipleImages, nil) + mockPackageManager.EXPECT().GetCodesphereVersion().Return("1.0.0", nil) + + // Expect calls for my-ubuntu-24.04 default flavor + mockImageManager.EXPECT().BuildImage("Dockerfile.default", "registry.example.com/my-ubuntu-24.04-default:1.0.0", ".").Return(nil) + mockImageManager.EXPECT().PushImage("registry.example.com/my-ubuntu-24.04-default:1.0.0").Return(nil) + + // Expect calls for my-ubuntu-24.04 minimal flavor + mockImageManager.EXPECT().BuildImage("Dockerfile.minimal", "registry.example.com/my-ubuntu-24.04-minimal:1.0.0", ".").Return(nil) + mockImageManager.EXPECT().PushImage("registry.example.com/my-ubuntu-24.04-minimal:1.0.0").Return(nil) + + // Expect calls for my-alpine-3.18 default flavor + mockImageManager.EXPECT().BuildImage("Dockerfile.alpine", "registry.example.com/my-alpine-3.18-default:1.0.0", ".").Return(nil) + mockImageManager.EXPECT().PushImage("registry.example.com/my-alpine-3.18-default:1.0.0").Return(nil) + + err := c.BuildAndPushImages(mockPackageManager, mockConfigManager, mockImageManager) + Expect(err).To(BeNil()) + }) + }) +}) + +var _ = Describe("AddBuildImagesCmd", func() { + var ( + parentCmd *cobra.Command + globalOpts *cmd.GlobalOptions + ) + + BeforeEach(func() { + parentCmd = &cobra.Command{Use: "build"} + globalOpts = &cmd.GlobalOptions{} + }) + + It("adds the images command with correct properties and flags", func() { + cmd.AddBuildImagesCmd(parentCmd, globalOpts) + + var imagesCmd *cobra.Command + for _, c := range parentCmd.Commands() { + if c.Use == "images" { + imagesCmd = c + break + } + } + + Expect(imagesCmd).NotTo(BeNil()) + Expect(imagesCmd.Use).To(Equal("images")) + Expect(imagesCmd.Short).To(Equal("Build and push container images")) + Expect(imagesCmd.Long).To(ContainSubstring("Build and push container images based on the configuration file")) + Expect(imagesCmd.RunE).NotTo(BeNil()) + + // Check flags + configFlag := imagesCmd.Flags().Lookup("config") + Expect(configFlag).NotTo(BeNil()) + Expect(configFlag.Shorthand).To(Equal("c")) + Expect(configFlag.Usage).To(ContainSubstring("Path to the configuration YAML file")) + }) +}) diff --git a/cli/cmd/download.go b/cli/cmd/download.go index af62a489..20b530c1 100644 --- a/cli/cmd/download.go +++ b/cli/cmd/download.go @@ -13,7 +13,7 @@ type DownloadCmd struct { cmd *cobra.Command } -func AddDownloadCmd(rootCmd *cobra.Command, opts GlobalOptions) { +func AddDownloadCmd(rootCmd *cobra.Command, opts *GlobalOptions) { download := DownloadCmd{ cmd: &cobra.Command{ Use: "download", diff --git a/cli/cmd/download_package.go b/cli/cmd/download_package.go index f05544cc..193a8d13 100644 --- a/cli/cmd/download_package.go +++ b/cli/cmd/download_package.go @@ -22,7 +22,7 @@ type DownloadPackageCmd struct { } type DownloadPackageOpts struct { - GlobalOptions + *GlobalOptions Version string Hash string Filename string @@ -50,7 +50,7 @@ func (c *DownloadPackageCmd) RunE(_ *cobra.Command, args []string) error { return nil } -func AddDownloadPackageCmd(download *cobra.Command, opts GlobalOptions) { +func AddDownloadPackageCmd(download *cobra.Command, opts *GlobalOptions) { pkg := DownloadPackageCmd{ cmd: &cobra.Command{ Use: "package", diff --git a/cli/cmd/extend_baseimage.go b/cli/cmd/extend_baseimage.go index 9cca02c3..248b7dcd 100644 --- a/cli/cmd/extend_baseimage.go +++ b/cli/cmd/extend_baseimage.go @@ -4,10 +4,10 @@ package cmd import ( + "context" "errors" "fmt" "log" - "path" "github.com/spf13/cobra" @@ -29,21 +29,20 @@ type ExtendBaseimageOpts struct { *GlobalOptions Package string Dockerfile string + Baseimage string Force bool } -const baseimagePath = "./codesphere/images" -const defaultBaseimage = "workspace-agent-24.04.tar" - func (c *ExtendBaseimageCmd) RunE(_ *cobra.Command, args []string) error { if c.Opts.Package == "" { return errors.New("required option package not set") } workdir := c.Env.GetOmsWorkdir() - p := installer.NewPackage(workdir, c.Opts.Package) + pm := installer.NewPackage(workdir, c.Opts.Package) + im := system.NewImage(context.Background()) - err := c.ExtendBaseimage(p, args) + err := c.ExtendBaseimage(pm, im) if err != nil { return fmt.Errorf("failed to extend baseimage: %w", err) } @@ -67,36 +66,35 @@ func AddExtendBaseimageCmd(extend *cobra.Command, opts *GlobalOptions) { } baseimage.cmd.Flags().StringVarP(&baseimage.Opts.Package, "package", "p", "", "Package file (e.g. codesphere-v1.2.3-installer.tar.gz) to load base image from") baseimage.cmd.Flags().StringVarP(&baseimage.Opts.Dockerfile, "dockerfile", "d", "Dockerfile", "Output Dockerfile to generate for extending the base image") + baseimage.cmd.Flags().StringVarP(&baseimage.Opts.Baseimage, "baseimage", "b", "workspace-agent-24.04", "Base image file name inside the package to extend (default: 'workspace-agent-24.04')") baseimage.cmd.Flags().BoolVarP(&baseimage.Opts.Force, "force", "f", false, "Enforce package extraction") + extend.AddCommand(baseimage.cmd) + baseimage.cmd.RunE = baseimage.RunE } -func (c *ExtendBaseimageCmd) ExtendBaseimage(p *installer.Package, args []string) error { - baseImageTarPath := path.Join(baseimagePath, defaultBaseimage) - err := p.ExtractDependency(baseImageTarPath, c.Opts.Force) +func (c *ExtendBaseimageCmd) ExtendBaseimage(pm installer.PackageManager, im system.ImageManager) error { + imageName, err := pm.GetBaseimageName(c.Opts.Baseimage) if err != nil { - return fmt.Errorf("failed to extract package to workdir: %w", err) + return fmt.Errorf("failed to get image name: %w", err) } - extractedBaseImagePath := p.GetDependencyPath(baseImageTarPath) - d := system.NewDockerEngine() - - imagenames, err := d.GetImageNames(p.FileIO, extractedBaseImagePath) - if err != nil || len(imagenames) == 0 { - return fmt.Errorf("failed to read image tags: %w", err) + imagePath, err := pm.GetBaseimagePath(c.Opts.Baseimage, c.Opts.Force) + if err != nil { + return fmt.Errorf("failed to get image path: %w", err) } - log.Println(imagenames) - err = tmpl.GenerateDockerfile(p.FileIO, c.Opts.Dockerfile, imagenames[0]) + err = tmpl.GenerateDockerfile(pm.FileIO(), c.Opts.Dockerfile, imageName) if err != nil { return fmt.Errorf("failed to generate dockerfile: %w", err) } - log.Printf("Loading container image from package into local docker daemon: %s", extractedBaseImagePath) - err = d.LoadLocalContainerImage(extractedBaseImagePath) + log.Printf("Loading container image from package into local docker daemon: %s", imagePath) + + err = im.LoadImage(imagePath) if err != nil { - return fmt.Errorf("failed to load baseimage file %s: %w", baseImageTarPath, err) + return fmt.Errorf("failed to load baseimage file %s: %w", imagePath, err) } return nil diff --git a/cli/cmd/extend_baseimage_test.go b/cli/cmd/extend_baseimage_test.go index 4c345b08..6b5dd8ed 100644 --- a/cli/cmd/extend_baseimage_test.go +++ b/cli/cmd/extend_baseimage_test.go @@ -4,9 +4,7 @@ package cmd_test import ( - "archive/tar" - "compress/gzip" - "fmt" + "errors" "os" . "github.com/onsi/ginkgo/v2" @@ -16,62 +14,23 @@ import ( "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/system" "github.com/codesphere-cloud/oms/internal/util" ) -// Helper function to create a test tar.gz file with binary data -func createTestTarGz(filename string, files map[string][]byte) error { - file, err := os.Create(filename) - if err != nil { - return err - } - defer func() { - _ = file.Close() - }() - - gzWriter := gzip.NewWriter(file) - defer func() { - _ = gzWriter.Close() - }() - - tarWriter := tar.NewWriter(gzWriter) - defer func() { - _ = tarWriter.Close() - }() - - for name, content := range files { - header := &tar.Header{ - Name: name, - Size: int64(len(content)), - Mode: 0644, - } - err := tarWriter.WriteHeader(header) - if err != nil { - return fmt.Errorf("failed to write header for file %q to tar: %w", name, err) - } - - _, err = tarWriter.Write(content) - if err != nil { - return fmt.Errorf("failed to write file %q to tar: %w", name, err) - } - } - - return nil -} - var _ = Describe("ExtendBaseimageCmd", func() { var ( c cmd.ExtendBaseimageCmd opts *cmd.ExtendBaseimageOpts - globalOpts cmd.GlobalOptions + globalOpts *cmd.GlobalOptions mockEnv *env.MockEnv ) BeforeEach(func() { mockEnv = env.NewMockEnv(GinkgoT()) - globalOpts = cmd.GlobalOptions{} + globalOpts = &cmd.GlobalOptions{} opts = &cmd.ExtendBaseimageOpts{ - GlobalOptions: &globalOpts, + GlobalOptions: globalOpts, Dockerfile: "Dockerfile", Force: false, } @@ -103,61 +62,92 @@ var _ = Describe("ExtendBaseimageCmd", func() { }) Context("ExtendBaseimage method", func() { - It("fails when package extraction fails due to missing package file", func() { - pkg := &installer.Package{ - OmsWorkdir: "/test/workdir", - Filename: "non-existent-package.tar.gz", - FileIO: &util.FilesystemWriter{}, - } + It("fails when package manager extraction fails", func() { + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) + mockImageManager := system.NewMockImageManager(GinkgoT()) + + mockPackageManager.EXPECT().GetBaseimageName("").Return("", errors.New("failed to get image name: extraction failed")) - err := c.ExtendBaseimage(pkg, []string{}) + err := c.ExtendBaseimage(mockPackageManager, mockImageManager) Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to extract package to workdir")) + Expect(err.Error()).To(ContainSubstring("failed to get image name")) }) - It("successfully extracts mocked package file", func() { - tempDir, err := os.MkdirTemp("", "oms-test-*") - Expect(err).To(BeNil()) - defer func() { - err := os.RemoveAll(tempDir) - Expect(err).To(BeNil()) - }() + It("fails when config manager fails to extract OCI image index", func() { + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) + mockImageManager := system.NewMockImageManager(GinkgoT()) - origWd, err := os.Getwd() - Expect(err).To(BeNil()) - err = os.Chdir(tempDir) - Expect(err).To(BeNil()) - defer func() { - err := os.Chdir(origWd) - Expect(err).To(BeNil()) - }() - - depsFile := "deps.tar.gz" - depsFiles := map[string][]byte{ - "codesphere/images/workspace-agent-24.04.tar": []byte("fake container image content"), - } - err = createTestTarGz(depsFile, depsFiles) - Expect(err).To(BeNil()) - depsContent, err := os.ReadFile(depsFile) - Expect(err).To(BeNil()) + mockPackageManager.EXPECT().GetBaseimageName("").Return("", errors.New("failed to extract OCI image index: index extraction failed")) - testPackageFile := "test-package.tar.gz" - packageFiles := map[string][]byte{ - "deps.tar.gz": depsContent, - } - err = createTestTarGz(testPackageFile, packageFiles) + err := c.ExtendBaseimage(mockPackageManager, mockImageManager) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to get image name")) + }) + + It("fails when OCI image index has no image names", func() { + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) + mockImageManager := system.NewMockImageManager(GinkgoT()) + + mockPackageManager.EXPECT().GetBaseimageName("").Return("", errors.New("failed to read image tags: no image names found")) + + err := c.ExtendBaseimage(mockPackageManager, mockImageManager) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to get image name")) + }) + + It("fails when image manager fails to load image", func() { + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) + mockImageManager := system.NewMockImageManager(GinkgoT()) + mockFileIO := util.NewMockFileIO(GinkgoT()) + + // Create a temporary file for the Dockerfile generation to work with + tempFile, err := os.CreateTemp("", "dockerfile-test-*") Expect(err).To(BeNil()) + defer func() { _ = os.Remove(tempFile.Name()) }() + defer func() { _ = tempFile.Close() }() + + mockPackageManager.EXPECT().GetBaseimageName("").Return("ubuntu:24.04-base", nil) + mockPackageManager.EXPECT().GetBaseimagePath("", false).Return("/test/workdir/deps/codesphere/images/workspace-agent-24.04.tar", nil) + mockPackageManager.EXPECT().FileIO().Return(mockFileIO) + mockFileIO.EXPECT().Create("Dockerfile").Return(tempFile, nil) + mockImageManager.EXPECT().LoadImage("/test/workdir/deps/codesphere/images/workspace-agent-24.04.tar").Return(errors.New("load failed")) + + err = c.ExtendBaseimage(mockPackageManager, mockImageManager) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to load baseimage file")) + }) + + It("uses force flag when extracting dependencies", func() { + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) + mockImageManager := system.NewMockImageManager(GinkgoT()) c.Opts.Force = true + mockPackageManager.EXPECT().GetBaseimageName("").Return("", errors.New("failed to extract package to workdir: extraction failed")) - pkg := &installer.Package{ - OmsWorkdir: tempDir, - Filename: testPackageFile, - FileIO: &util.FilesystemWriter{}, - } - err = c.ExtendBaseimage(pkg, []string{}) + err := c.ExtendBaseimage(mockPackageManager, mockImageManager) Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to read image tags")) + Expect(err.Error()).To(ContainSubstring("failed to get image name")) + }) + + It("successfully completes workflow until dockerfile generation", func() { + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) + mockImageManager := system.NewMockImageManager(GinkgoT()) + mockFileIO := util.NewMockFileIO(GinkgoT()) + + // Create a temporary file for the Dockerfile generation to work with + tempFile, err := os.CreateTemp("", "dockerfile-test-*") + Expect(err).To(BeNil()) + defer func() { _ = os.Remove(tempFile.Name()) }() + defer func() { _ = tempFile.Close() }() + + mockPackageManager.EXPECT().GetBaseimageName("").Return("ubuntu:24.04-base", nil) + mockPackageManager.EXPECT().GetBaseimagePath("", false).Return("/test/workdir/deps/codesphere/images/workspace-agent-24.04.tar", nil) + mockPackageManager.EXPECT().FileIO().Return(mockFileIO) + mockFileIO.EXPECT().Create("Dockerfile").Return(tempFile, nil) + mockImageManager.EXPECT().LoadImage("/test/workdir/deps/codesphere/images/workspace-agent-24.04.tar").Return(nil) + + err = c.ExtendBaseimage(mockPackageManager, mockImageManager) + Expect(err).To(BeNil()) }) }) }) diff --git a/cli/cmd/install_codesphere.go b/cli/cmd/install_codesphere.go index 59b65794..ebbfa8be 100644 --- a/cli/cmd/install_codesphere.go +++ b/cli/cmd/install_codesphere.go @@ -4,17 +4,21 @@ package cmd import ( + "context" "fmt" "log" "os" "os/exec" + "path" "path/filepath" "runtime" "slices" + "strings" "github.com/codesphere-cloud/cs-go/pkg/io" "github.com/codesphere-cloud/oms/internal/env" "github.com/codesphere-cloud/oms/internal/installer" + "github.com/codesphere-cloud/oms/internal/system" "github.com/codesphere-cloud/oms/internal/util" "github.com/spf13/cobra" ) @@ -37,9 +41,11 @@ type InstallCodesphereOpts struct { func (c *InstallCodesphereCmd) RunE(_ *cobra.Command, args []string) error { workdir := c.Env.GetOmsWorkdir() - p := installer.NewPackage(workdir, c.Opts.Package) + pm := installer.NewPackage(workdir, c.Opts.Package) + cm := installer.NewConfig() + im := system.NewImage(context.Background()) - err := c.ExtractAndInstall(p, runtime.GOOS, runtime.GOARCH) + err := c.ExtractAndInstall(pm, cm, im, runtime.GOOS, runtime.GOARCH) if err != nil { return fmt.Errorf("failed to extract and install package: %w", err) } @@ -69,20 +75,26 @@ func AddInstallCodesphereCmd(install *cobra.Command, opts *GlobalOptions) { util.MarkFlagRequired(codesphere.cmd, "priv-key") install.AddCommand(codesphere.cmd) + codesphere.cmd.RunE = codesphere.RunE } -func (c *InstallCodesphereCmd) ExtractAndInstall(p *installer.Package, goos string, goarch string) error { +func (c *InstallCodesphereCmd) ExtractAndInstall(pm installer.PackageManager, cm installer.ConfigManager, im system.ImageManager, goos string, goarch string) error { if goos != "linux" || goarch != "amd64" { return fmt.Errorf("codesphere installation is only supported on Linux amd64. Current platform: %s/%s", goos, goarch) } - err := p.Extract(c.Opts.Force) + config, err := cm.ParseConfigYaml(c.Opts.Config) + if err != nil { + return fmt.Errorf("failed to extract config.yaml: %w", err) + } + + err = pm.Extract(c.Opts.Force) if err != nil { return fmt.Errorf("failed to extract package to workdir: %w", err) } - foundFiles, err := c.ListPackageContents(p) + foundFiles, err := c.ListPackageContents(pm) if err != nil { return fmt.Errorf("failed to list available files: %w", err) } @@ -97,7 +109,61 @@ func (c *InstallCodesphereCmd) ExtractAndInstall(p *installer.Package, goos stri return fmt.Errorf("node executable not found in package") } - nodePath := filepath.Join(".", p.GetWorkDir(), "node") + // If workspace image is extended extract bom.json and load workspace image + dockerfiles := config.ExtractWorkspaceDockerfiles() + if len(dockerfiles) > 0 { + err = pm.ExtractDependency("bom.json", c.Opts.Force) + if err != nil { + return fmt.Errorf("failed to extract package to workdir: %w", err) + } + + for dockerfile, bomRef := range dockerfiles { + rootImageName := c.ExtractRootImageName(bomRef) + imagePath := filepath.Join("codesphere", "images", fmt.Sprintf("%s.tar", rootImageName)) + err = pm.ExtractDependency(imagePath, c.Opts.Force) + if err != nil { + return fmt.Errorf("failed to extract root image %s: %w", imagePath, err) + } + + extractedImagePath := pm.GetDependencyPath(imagePath) + err = im.LoadImage(extractedImagePath) + if err != nil { + return fmt.Errorf("failed to load workspace image from Dockerfile %s: %w", dockerfile, err) + } + log.Printf("Loaded root image '%s'", extractedImagePath) + + // TODO: This is duplicated from update_dockerfile.go, refactor into shared function + dockerfileFile, err := pm.FileIO().Open(dockerfile) + if err != nil { + return fmt.Errorf("failed to open dockerfile %s: %w", dockerfile, err) + } + defer util.CloseFileIgnoreError(dockerfileFile) + + dockerfileManager := util.NewDockerfileManager() + updatedContent, err := dockerfileManager.UpdateFromStatement(dockerfileFile, rootImageName) + if err != nil { + return fmt.Errorf("failed to update FROM statement: %w", err) + } + + err = pm.FileIO().WriteFile(dockerfile, []byte(updatedContent), 0644) + if err != nil { + return fmt.Errorf("failed to write updated dockerfile: %w", err) + } + + log.Printf("Successfully updated FROM statement in %s to use %s", dockerfile, rootImageName) + // TODO: End duplicated code + + dockerfileName := filepath.Base(dockerfile) + dockerfileDir := filepath.Dir(dockerfile) + err = im.BuildImage(dockerfileName, rootImageName, dockerfileDir) + if err != nil { + return fmt.Errorf("failed to build workspace image from Dockerfile %s: %w", dockerfile, err) + } + } + } + + // Install codesphere with node + nodePath := filepath.Join(".", pm.GetWorkDir(), "node") err = os.Chmod(nodePath, 0755) if err != nil { return fmt.Errorf("failed to make node executable: %w", err) @@ -105,10 +171,9 @@ func (c *InstallCodesphereCmd) ExtractAndInstall(p *installer.Package, goos stri log.Printf("Using Node.js executable: %s", nodePath) log.Println("Starting private cloud installer script...") - installerPath := filepath.Join(".", p.GetWorkDir(), "private-cloud-installer.js") - archivePath := filepath.Join(".", p.GetWorkDir(), "deps.tar.gz") + installerPath := filepath.Join(".", pm.GetWorkDir(), "private-cloud-installer.js") + archivePath := filepath.Join(".", pm.GetWorkDir(), "deps.tar.gz") - // Build command cmdArgs := []string{installerPath, "--archive", archivePath, "--config", c.Opts.Config, "--privKey", c.Opts.PrivKey} if len(c.Opts.SkipSteps) > 0 { for _, step := range c.Opts.SkipSteps { @@ -130,13 +195,13 @@ func (c *InstallCodesphereCmd) ExtractAndInstall(p *installer.Package, goos stri return nil } -func (c *InstallCodesphereCmd) ListPackageContents(p *installer.Package) ([]string, error) { - packageDir := p.GetWorkDir() - if !p.FileIO.Exists(packageDir) { +func (c *InstallCodesphereCmd) ListPackageContents(pm installer.PackageManager) ([]string, error) { + packageDir := pm.GetWorkDir() + if !pm.FileIO().Exists(packageDir) { return nil, fmt.Errorf("work dir not found: %s", packageDir) } - entries, err := p.FileIO.ReadDir(packageDir) + entries, err := pm.FileIO().ReadDir(packageDir) if err != nil { return nil, fmt.Errorf("failed to read directory contents: %w", err) } @@ -151,3 +216,13 @@ func (c *InstallCodesphereCmd) ListPackageContents(p *installer.Package) ([]stri return foundFiles, nil } + +// ExtractRootImageName extracts the root image name from a bomRef string. +func (c *InstallCodesphereCmd) ExtractRootImageName(bomRef string) string { + parts := strings.Split(bomRef, ":") + if len(parts) < 2 { + return bomRef + } + + return path.Base(parts[0]) +} diff --git a/cli/cmd/install_codesphere_test.go b/cli/cmd/install_codesphere_test.go index bcb04db7..18ff6f29 100644 --- a/cli/cmd/install_codesphere_test.go +++ b/cli/cmd/install_codesphere_test.go @@ -4,6 +4,7 @@ package cmd_test import ( + "errors" "os" "runtime" @@ -14,6 +15,8 @@ import ( "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/installer/files" + "github.com/codesphere-cloud/oms/internal/system" "github.com/codesphere-cloud/oms/internal/util" ) @@ -21,15 +24,15 @@ var _ = Describe("InstallCodesphereCmd", func() { var ( c cmd.InstallCodesphereCmd opts *cmd.InstallCodesphereOpts - globalOpts cmd.GlobalOptions + globalOpts *cmd.GlobalOptions mockEnv *env.MockEnv ) BeforeEach(func() { mockEnv = env.NewMockEnv(GinkgoT()) - globalOpts = cmd.GlobalOptions{} + globalOpts = &cmd.GlobalOptions{} opts = &cmd.InstallCodesphereOpts{ - GlobalOptions: &globalOpts, + GlobalOptions: globalOpts, Package: "codesphere-v1.66.0-installer.tar.gz", Force: false, } @@ -46,9 +49,19 @@ var _ = Describe("InstallCodesphereCmd", func() { Context("RunE method", func() { It("calls GetOmsWorkdir and fails on non-linux platform", func() { c.Opts.Package = "test-package.tar.gz" + + tempConfigFile, err := os.CreateTemp("", "test-config.yaml") + Expect(err).To(BeNil()) + defer func() { _ = os.Remove(tempConfigFile.Name()) }() + + _, err = tempConfigFile.WriteString("codesphere:\n deployConfig:\n images: {}\n") + Expect(err).To(BeNil()) + _ = tempConfigFile.Close() + + c.Opts.Config = tempConfigFile.Name() mockEnv.EXPECT().GetOmsWorkdir().Return("/test/workdir") - err := c.RunE(nil, []string{}) + err = c.RunE(nil, []string{}) Expect(err).To(HaveOccurred()) if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { @@ -56,254 +69,277 @@ var _ = Describe("InstallCodesphereCmd", func() { Expect(err.Error()).To(ContainSubstring("codesphere installation is only supported on Linux amd64")) } else { // On Linux amd64, it should fail on package extraction since the package doesn't exist - Expect(err.Error()).To(ContainSubstring("failed to extract package to workdir")) + Expect(err.Error()).To(ContainSubstring("failed to extract and install package")) } }) }) Context("ExtractAndInstall method", func() { It("fails on non-linux amd64 platforms", func() { - pkg := &installer.Package{ - OmsWorkdir: "/test/workdir", - Filename: "test-package.tar.gz", - FileIO: &util.FilesystemWriter{}, - } + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) + mockConfigManager := installer.NewMockConfigManager(GinkgoT()) + mockImageManager := system.NewMockImageManager(GinkgoT()) // Test with Windows platform - err := c.ExtractAndInstall(pkg, "windows", "amd64") + err := c.ExtractAndInstall(mockPackageManager, mockConfigManager, mockImageManager, "windows", "amd64") Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("codesphere installation is only supported on Linux amd64")) Expect(err.Error()).To(ContainSubstring("windows/amd64")) // Test with ARM64 architecture - err = c.ExtractAndInstall(pkg, "linux", "arm64") + err = c.ExtractAndInstall(mockPackageManager, mockConfigManager, mockImageManager, "linux", "arm64") 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("when on Linux amd64", func() { + It("fails when config parsing fails", func() { + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) + mockConfigManager := installer.NewMockConfigManager(GinkgoT()) + mockImageManager := system.NewMockImageManager(GinkgoT()) + + c.Opts.Config = "invalid-config.yaml" + mockConfigManager.EXPECT().ParseConfigYaml("invalid-config.yaml").Return(files.RootConfig{}, errors.New("config parse error")) + + err := c.ExtractAndInstall(mockPackageManager, mockConfigManager, mockImageManager, "linux", "amd64") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to extract config.yaml")) + }) + It("fails when package extraction fails", func() { - pkg := &installer.Package{ - OmsWorkdir: "/test/workdir", - Filename: "non-existent-package.tar.gz", - FileIO: &util.FilesystemWriter{}, - } + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) + mockConfigManager := installer.NewMockConfigManager(GinkgoT()) + mockImageManager := system.NewMockImageManager(GinkgoT()) + + c.Opts.Config = "valid-config.yaml" + mockConfigManager.EXPECT().ParseConfigYaml("valid-config.yaml").Return(files.RootConfig{}, nil) + mockPackageManager.EXPECT().Extract(false).Return(errors.New("extraction failed")) - err := c.ExtractAndInstall(pkg, "linux", "amd64") + err := c.ExtractAndInstall(mockPackageManager, mockConfigManager, mockImageManager, "linux", "amd64") Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("failed to extract package to workdir")) }) + It("fails when package listing fails", func() { + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) + mockConfigManager := installer.NewMockConfigManager(GinkgoT()) + mockImageManager := system.NewMockImageManager(GinkgoT()) + mockFileIO := util.NewMockFileIO(GinkgoT()) + + c.Opts.Config = "valid-config.yaml" + mockConfigManager.EXPECT().ParseConfigYaml("valid-config.yaml").Return(files.RootConfig{}, nil) + mockPackageManager.EXPECT().Extract(false).Return(nil) + mockPackageManager.EXPECT().GetWorkDir().Return("/test/workdir/package") + mockPackageManager.EXPECT().FileIO().Return(mockFileIO) + mockFileIO.EXPECT().Exists("/test/workdir/package").Return(false) + + err := c.ExtractAndInstall(mockPackageManager, mockConfigManager, mockImageManager, "linux", "amd64") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to list available files")) + }) + It("fails when deps.tar.gz is missing from package", func() { - tempDir, err := os.MkdirTemp("", "oms-test-*") - Expect(err).To(BeNil()) - defer func() { - err := os.RemoveAll(tempDir) - Expect(err).To(BeNil()) - }() - - origWd, err := os.Getwd() - Expect(err).To(BeNil()) - err = os.Chdir(tempDir) - Expect(err).To(BeNil()) - defer func() { - err := os.Chdir(origWd) - Expect(err).To(BeNil()) - }() - - // Create package without deps.tar.gz - testPackageFile := "test-package.tar.gz" - packageFiles := map[string][]byte{ - "node": []byte("fake node binary"), - "private-cloud-installer.js": []byte("console.log('installer');"), - "kubectl": []byte("fake kubectl binary"), - // deps.tar.gz missing - } - err = createTestTarGz(testPackageFile, packageFiles) - Expect(err).To(BeNil()) - - c.Opts.Force = true - pkg := &installer.Package{ - OmsWorkdir: tempDir, - Filename: testPackageFile, - FileIO: &util.FilesystemWriter{}, + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) + mockConfigManager := installer.NewMockConfigManager(GinkgoT()) + mockImageManager := system.NewMockImageManager(GinkgoT()) + mockFileIO := util.NewMockFileIO(GinkgoT()) + + c.Opts.Config = "valid-config.yaml" + mockConfigManager.EXPECT().ParseConfigYaml("valid-config.yaml").Return(files.RootConfig{}, nil) + mockPackageManager.EXPECT().Extract(false).Return(nil) + mockPackageManager.EXPECT().GetWorkDir().Return("/test/workdir/package") + mockPackageManager.EXPECT().FileIO().Return(mockFileIO) + mockFileIO.EXPECT().Exists("/test/workdir/package").Return(true) + + // Create mock directory entries without deps.tar.gz + mockEntries := []os.DirEntry{ + &MockDirEntry{name: "node", isDir: false}, + &MockDirEntry{name: "private-cloud-installer.js", isDir: false}, + &MockDirEntry{name: "kubectl", isDir: false}, } + mockFileIO.EXPECT().ReadDir("/test/workdir/package").Return(mockEntries, nil) - err = c.ExtractAndInstall(pkg, "linux", "amd64") + err := c.ExtractAndInstall(mockPackageManager, mockConfigManager, mockImageManager, "linux", "amd64") Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("deps.tar.gz not found in package")) }) It("fails when private-cloud-installer.js is missing from package", func() { - tempDir, err := os.MkdirTemp("", "oms-test-*") - Expect(err).To(BeNil()) - defer func() { - err := os.RemoveAll(tempDir) - Expect(err).To(BeNil()) - }() - - origWd, err := os.Getwd() - Expect(err).To(BeNil()) - err = os.Chdir(tempDir) - Expect(err).To(BeNil()) - defer func() { - err := os.Chdir(origWd) - Expect(err).To(BeNil()) - }() - - // Create package without private-cloud-installer.js - testPackageFile := "test-package.tar.gz" - packageFiles := map[string][]byte{ - "deps.tar.gz": []byte("fake deps archive"), - "node": []byte("fake node binary"), - "kubectl": []byte("fake kubectl binary"), - // private-cloud-installer.js missing - } - err = createTestTarGz(testPackageFile, packageFiles) - Expect(err).To(BeNil()) - - c.Opts.Force = true - pkg := &installer.Package{ - OmsWorkdir: tempDir, - Filename: testPackageFile, - FileIO: &util.FilesystemWriter{}, + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) + mockConfigManager := installer.NewMockConfigManager(GinkgoT()) + mockImageManager := system.NewMockImageManager(GinkgoT()) + mockFileIO := util.NewMockFileIO(GinkgoT()) + + c.Opts.Config = "valid-config.yaml" + mockConfigManager.EXPECT().ParseConfigYaml("valid-config.yaml").Return(files.RootConfig{}, nil) + mockPackageManager.EXPECT().Extract(false).Return(nil) + mockPackageManager.EXPECT().GetWorkDir().Return("/test/workdir/package") + mockPackageManager.EXPECT().FileIO().Return(mockFileIO) + mockFileIO.EXPECT().Exists("/test/workdir/package").Return(true) + + // Create mock directory entries without private-cloud-installer.js + mockEntries := []os.DirEntry{ + &MockDirEntry{name: "deps.tar.gz", isDir: false}, + &MockDirEntry{name: "node", isDir: false}, + &MockDirEntry{name: "kubectl", isDir: false}, } + mockFileIO.EXPECT().ReadDir("/test/workdir/package").Return(mockEntries, nil) - err = c.ExtractAndInstall(pkg, "linux", "amd64") + err := c.ExtractAndInstall(mockPackageManager, mockConfigManager, mockImageManager, "linux", "amd64") Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("private-cloud-installer.js not found in package")) }) It("fails when node executable is missing from package", func() { - tempDir, err := os.MkdirTemp("", "oms-test-*") - Expect(err).To(BeNil()) - defer func() { - err := os.RemoveAll(tempDir) - Expect(err).To(BeNil()) - }() - - origWd, err := os.Getwd() - Expect(err).To(BeNil()) - err = os.Chdir(tempDir) - Expect(err).To(BeNil()) - defer func() { - err := os.Chdir(origWd) - Expect(err).To(BeNil()) - }() - - // Create package without node executable - testPackageFile := "test-package.tar.gz" - packageFiles := map[string][]byte{ - "deps.tar.gz": []byte("fake deps archive"), - "private-cloud-installer.js": []byte("console.log('installer');"), - "kubectl": []byte("fake kubectl binary"), - // node missing - } - err = createTestTarGz(testPackageFile, packageFiles) - Expect(err).To(BeNil()) - - c.Opts.Force = true - pkg := &installer.Package{ - OmsWorkdir: tempDir, - Filename: testPackageFile, - FileIO: &util.FilesystemWriter{}, + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) + mockConfigManager := installer.NewMockConfigManager(GinkgoT()) + mockImageManager := system.NewMockImageManager(GinkgoT()) + mockFileIO := util.NewMockFileIO(GinkgoT()) + + c.Opts.Config = "valid-config.yaml" + mockConfigManager.EXPECT().ParseConfigYaml("valid-config.yaml").Return(files.RootConfig{}, nil) + mockPackageManager.EXPECT().Extract(false).Return(nil) + mockPackageManager.EXPECT().GetWorkDir().Return("/test/workdir/package") + mockPackageManager.EXPECT().FileIO().Return(mockFileIO) + mockFileIO.EXPECT().Exists("/test/workdir/package").Return(true) + + // Create mock directory entries without node executable + mockEntries := []os.DirEntry{ + &MockDirEntry{name: "deps.tar.gz", isDir: false}, + &MockDirEntry{name: "private-cloud-installer.js", isDir: false}, + &MockDirEntry{name: "kubectl", isDir: false}, } + mockFileIO.EXPECT().ReadDir("/test/workdir/package").Return(mockEntries, nil) - err = c.ExtractAndInstall(pkg, "linux", "amd64") + err := c.ExtractAndInstall(mockPackageManager, mockConfigManager, mockImageManager, "linux", "amd64") Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("node executable not found in package")) }) - It("successfully extracts package with all required files but fails on execution", func() { - tempDir, err := os.MkdirTemp("", "oms-test-*") - Expect(err).To(BeNil()) - defer func() { - err := os.RemoveAll(tempDir) - Expect(err).To(BeNil()) - }() - - origWd, err := os.Getwd() - Expect(err).To(BeNil()) - err = os.Chdir(tempDir) - Expect(err).To(BeNil()) - defer func() { - err := os.Chdir(origWd) - Expect(err).To(BeNil()) - }() - - // Create complete package with all required files - testPackageFile := "test-package.tar.gz" - packageFiles := map[string][]byte{ - "deps.tar.gz": []byte("fake deps archive"), - "node": []byte("fake node binary that will fail to execute"), - "private-cloud-installer.js": []byte("console.log('installer');"), - "kubectl": []byte("fake kubectl binary"), + It("successfully validates all required files but fails on execution", func() { + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) + mockConfigManager := installer.NewMockConfigManager(GinkgoT()) + mockImageManager := system.NewMockImageManager(GinkgoT()) + mockFileIO := util.NewMockFileIO(GinkgoT()) + + c.Opts.Config = "valid-config.yaml" + c.Opts.PrivKey = "test-key.pem" + mockConfigManager.EXPECT().ParseConfigYaml("valid-config.yaml").Return(files.RootConfig{}, nil) + mockPackageManager.EXPECT().Extract(false).Return(nil) + mockPackageManager.EXPECT().GetWorkDir().Return("/test/workdir/package") + mockPackageManager.EXPECT().FileIO().Return(mockFileIO) + mockFileIO.EXPECT().Exists("/test/workdir/package").Return(true) + + // Create complete mock directory entries with all required files + mockEntries := []os.DirEntry{ + &MockDirEntry{name: "deps.tar.gz", isDir: false}, + &MockDirEntry{name: "node", isDir: false}, + &MockDirEntry{name: "private-cloud-installer.js", isDir: false}, + &MockDirEntry{name: "kubectl", isDir: false}, + } + mockFileIO.EXPECT().ReadDir("/test/workdir/package").Return(mockEntries, nil) + + err := c.ExtractAndInstall(mockPackageManager, mockConfigManager, mockImageManager, "linux", "amd64") + Expect(err).To(HaveOccurred()) + // Should fail when trying to make fake node executable + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to make node executable")) + }) + + It("successfully builds and pushes custom workspace images", func() { + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) + mockConfigManager := installer.NewMockConfigManager(GinkgoT()) + mockImageManager := system.NewMockImageManager(GinkgoT()) + mockFileIO := util.NewMockFileIO(GinkgoT()) + + c.Opts.Config = "valid-config.yaml" + c.Opts.PrivKey = "test-key.pem" + + // Create config with workspace dockerfiles + config := files.RootConfig{ + Codesphere: files.CodesphereConfig{ + DeployConfig: files.DeployConfig{ + Images: map[string]files.ImageConfig{ + "ubuntu-24.04": { + Flavors: map[string]files.FlavorConfig{ + "default": { + Image: files.ImageRef{ + BomRef: "docker.io/library/ubuntu:24.04", + Dockerfile: "workspace.Dockerfile", + }, + }, + }, + }, + }, + }, + }, } - err = createTestTarGz(testPackageFile, packageFiles) - Expect(err).To(BeNil()) - - c.Opts.Force = true - pkg := &installer.Package{ - OmsWorkdir: tempDir, - Filename: testPackageFile, - FileIO: &util.FilesystemWriter{}, + + mockConfigManager.EXPECT().ParseConfigYaml("valid-config.yaml").Return(config, nil) + mockPackageManager.EXPECT().Extract(false).Return(nil) + mockPackageManager.EXPECT().GetWorkDir().Return("/test/workdir/package") + mockPackageManager.EXPECT().FileIO().Return(mockFileIO) + mockFileIO.EXPECT().Exists("/test/workdir/package").Return(true) + + // Create complete mock directory entries with all required files + mockEntries := []os.DirEntry{ + &MockDirEntry{name: "deps.tar.gz", isDir: false}, + &MockDirEntry{name: "node", isDir: false}, + &MockDirEntry{name: "private-cloud-installer.js", isDir: false}, + &MockDirEntry{name: "kubectl", isDir: false}, } + mockFileIO.EXPECT().ReadDir("/test/workdir/package").Return(mockEntries, nil) + + mockPackageManager.EXPECT().ExtractDependency("bom.json", false).Return(nil) + mockPackageManager.EXPECT().ExtractDependency("codesphere/images/ubuntu.tar", false).Return(nil) + mockPackageManager.EXPECT().GetDependencyPath("codesphere/images/ubuntu.tar").Return("/test/workdir/deps/codesphere/images/ubuntu.tar") + mockImageManager.EXPECT().LoadImage("/test/workdir/deps/codesphere/images/ubuntu.tar").Return(nil) - err = c.ExtractAndInstall(pkg, "linux", "amd64") + // Mock for reading the Dockerfile + mockFileIO.EXPECT().Open("workspace.Dockerfile").Return(nil, errors.New("dockerfile not found")) + + err := c.ExtractAndInstall(mockPackageManager, mockConfigManager, mockImageManager, "linux", "amd64") + // Should fail when trying to read the dockerfile Expect(err).To(HaveOccurred()) - // Should fail when trying to chmod or execute the fake node binary - Expect(err.Error()).To(SatisfyAny( - ContainSubstring("failed to make node executable"), - ContainSubstring("failed to run installer script"), - )) + Expect(err.Error()).To(ContainSubstring("dockerfile not found")) }) }) }) Context("listPackageContents method", func() { It("fails when work directory doesn't exist", func() { + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) mockFileIO := util.NewMockFileIO(GinkgoT()) - pkg := &installer.Package{ - OmsWorkdir: "/test/workdir", - Filename: "test-package.tar.gz", - FileIO: mockFileIO, - } - mockFileIO.EXPECT().Exists("/test/workdir/test-package").Return(false) + mockPackageManager.EXPECT().GetWorkDir().Return("/test/workdir/package") + mockPackageManager.EXPECT().FileIO().Return(mockFileIO) + mockFileIO.EXPECT().Exists("/test/workdir/package").Return(false) - filenames, err := c.ListPackageContents(pkg) + filenames, err := c.ListPackageContents(mockPackageManager) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("work dir not found")) Expect(filenames).To(BeNil()) - mockFileIO.AssertExpectations(GinkgoT()) }) It("fails when ReadDir fails", func() { + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) mockFileIO := util.NewMockFileIO(GinkgoT()) - pkg := &installer.Package{ - OmsWorkdir: "/test/workdir", - Filename: "test-package.tar.gz", - FileIO: mockFileIO, - } - mockFileIO.EXPECT().Exists("/test/workdir/test-package").Return(true) - mockFileIO.EXPECT().ReadDir("/test/workdir/test-package").Return(nil, os.ErrPermission) + mockPackageManager.EXPECT().GetWorkDir().Return("/test/workdir/package") + mockPackageManager.EXPECT().FileIO().Return(mockFileIO) + mockFileIO.EXPECT().Exists("/test/workdir/package").Return(true) + mockFileIO.EXPECT().ReadDir("/test/workdir/package").Return(nil, os.ErrPermission) - filenames, err := c.ListPackageContents(pkg) + filenames, err := c.ListPackageContents(mockPackageManager) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("failed to read directory contents")) Expect(filenames).To(BeNil()) - mockFileIO.AssertExpectations(GinkgoT()) }) It("successfully lists package contents", func() { + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) mockFileIO := util.NewMockFileIO(GinkgoT()) - pkg := &installer.Package{ - OmsWorkdir: "/test/workdir", - Filename: "test-package.tar.gz", - FileIO: mockFileIO, - } // Create mock directory entries mockEntries := []os.DirEntry{ @@ -313,17 +349,18 @@ var _ = Describe("InstallCodesphereCmd", func() { &MockDirEntry{name: "kubectl", isDir: false}, } - mockFileIO.EXPECT().Exists("/test/workdir/test-package").Return(true) - mockFileIO.EXPECT().ReadDir("/test/workdir/test-package").Return(mockEntries, nil) + mockPackageManager.EXPECT().GetWorkDir().Return("/test/workdir/package") + mockPackageManager.EXPECT().FileIO().Return(mockFileIO) + mockFileIO.EXPECT().Exists("/test/workdir/package").Return(true) + mockFileIO.EXPECT().ReadDir("/test/workdir/package").Return(mockEntries, nil) - filenames, err := c.ListPackageContents(pkg) + filenames, err := c.ListPackageContents(mockPackageManager) Expect(err).To(BeNil()) Expect(filenames).To(HaveLen(4)) Expect(filenames).To(ContainElement("deps.tar.gz")) Expect(filenames).To(ContainElement("node")) Expect(filenames).To(ContainElement("private-cloud-installer.js")) Expect(filenames).To(ContainElement("kubectl")) - mockFileIO.AssertExpectations(GinkgoT()) }) }) }) diff --git a/cli/cmd/list.go b/cli/cmd/list.go index 6620bed0..5b406e71 100644 --- a/cli/cmd/list.go +++ b/cli/cmd/list.go @@ -12,7 +12,7 @@ type ListCmd struct { cmd *cobra.Command } -func AddListCmd(rootCmd *cobra.Command, opts GlobalOptions) { +func AddListCmd(rootCmd *cobra.Command, opts *GlobalOptions) { list := ListCmd{ cmd: &cobra.Command{ Use: "list", diff --git a/cli/cmd/list_api_keys.go b/cli/cmd/list_api_keys.go index da0429d8..96d318a1 100644 --- a/cli/cmd/list_api_keys.go +++ b/cli/cmd/list_api_keys.go @@ -30,7 +30,7 @@ func (c *ListAPIKeysCmd) RunE(_ *cobra.Command, args []string) error { return nil } -func AddListAPIKeysCmd(list *cobra.Command, opts GlobalOptions) { +func AddListAPIKeysCmd(list *cobra.Command, opts *GlobalOptions) { c := ListAPIKeysCmd{ cmd: &cobra.Command{ Use: "api-keys", diff --git a/cli/cmd/list_packages.go b/cli/cmd/list_packages.go index c58538ec..2a0bbdfd 100644 --- a/cli/cmd/list_packages.go +++ b/cli/cmd/list_packages.go @@ -21,7 +21,7 @@ type ListBuildsCmd struct { } type ListBuildsOpts struct { - GlobalOptions + *GlobalOptions Internal bool } @@ -36,7 +36,7 @@ func (c *ListBuildsCmd) RunE(_ *cobra.Command, args []string) error { return nil } -func AddListPackagesCmd(list *cobra.Command, opts GlobalOptions) { +func AddListPackagesCmd(list *cobra.Command, opts *GlobalOptions) { builds := ListBuildsCmd{ cmd: &cobra.Command{ Use: "packages", diff --git a/cli/cmd/register.go b/cli/cmd/register.go index e621a604..c1939ecf 100644 --- a/cli/cmd/register.go +++ b/cli/cmd/register.go @@ -18,7 +18,7 @@ type RegisterCmd struct { } type RegisterOpts struct { - GlobalOptions + *GlobalOptions Owner string Organization string Role string @@ -57,7 +57,7 @@ func (c *RegisterCmd) Register(p portal.Portal) (*portal.ApiKey, error) { return newKey, nil } -func AddRegisterCmd(list *cobra.Command, opts GlobalOptions) { +func AddRegisterCmd(list *cobra.Command, opts *GlobalOptions) { c := RegisterCmd{ cmd: &cobra.Command{ Use: "register", diff --git a/cli/cmd/register_test.go b/cli/cmd/register_test.go index 11713077..e3a9bd09 100644 --- a/cli/cmd/register_test.go +++ b/cli/cmd/register_test.go @@ -74,7 +74,7 @@ var _ = Describe("RegisterCmd", func() { var _ = Describe("AddRegisterCmd", func() { It("adds the register command to the parent", func() { parent := &cobra.Command{} - opts := cmd.GlobalOptions{} + opts := &cmd.GlobalOptions{} cmd.AddRegisterCmd(parent, opts) found := false for _, c := range parent.Commands() { diff --git a/cli/cmd/revoke.go b/cli/cmd/revoke.go index 14c73562..f0ace88c 100644 --- a/cli/cmd/revoke.go +++ b/cli/cmd/revoke.go @@ -12,7 +12,7 @@ type RevokeCmd struct { cmd *cobra.Command } -func AddRevokeCmd(rootCmd *cobra.Command, opts GlobalOptions) { +func AddRevokeCmd(rootCmd *cobra.Command, opts *GlobalOptions) { revoke := RevokeCmd{ cmd: &cobra.Command{ Use: "revoke", diff --git a/cli/cmd/revoke_api_key.go b/cli/cmd/revoke_api_key.go index 5ab5248b..3af7148a 100644 --- a/cli/cmd/revoke_api_key.go +++ b/cli/cmd/revoke_api_key.go @@ -18,7 +18,7 @@ type RevokeAPIKeyCmd struct { } type RevokeAPIKeyOpts struct { - GlobalOptions + *GlobalOptions ID string } @@ -36,7 +36,7 @@ func (c *RevokeAPIKeyCmd) Revoke(p portal.Portal) error { return nil } -func AddRevokeAPIKeyCmd(list *cobra.Command, opts GlobalOptions) { +func AddRevokeAPIKeyCmd(list *cobra.Command, opts *GlobalOptions) { c := RevokeAPIKeyCmd{ cmd: &cobra.Command{ Use: "api-key", diff --git a/cli/cmd/revoke_api_key_test.go b/cli/cmd/revoke_api_key_test.go index 0cd10724..a2ed0c95 100644 --- a/cli/cmd/revoke_api_key_test.go +++ b/cli/cmd/revoke_api_key_test.go @@ -51,7 +51,7 @@ var _ = Describe("RevokeCmd", func() { var _ = Describe("AddRevokeAPIKeyCmd", func() { It("adds the api-key command to the parent", func() { parent := &cobra.Command{} - opts := cmd.GlobalOptions{} + opts := &cmd.GlobalOptions{} cmd.AddRevokeAPIKeyCmd(parent, opts) found := false for _, c := range parent.Commands() { diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 2264f7d6..431e5cd9 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -17,7 +17,7 @@ type GlobalOptions struct { // GetRootCmd adds all child commands to the root command and sets flags appropriately. func GetRootCmd() *cobra.Command { - opts := GlobalOptions{} + opts := &GlobalOptions{} rootCmd := &cobra.Command{ Use: "oms", Short: "Codesphere Operations Management System (OMS)", @@ -28,13 +28,14 @@ func GetRootCmd() *cobra.Command { } // General commands AddVersionCmd(rootCmd) - AddBetaCmd(rootCmd, &opts) + AddBetaCmd(rootCmd, opts) AddUpdateCmd(rootCmd, opts) // Package commands AddListCmd(rootCmd, opts) AddDownloadCmd(rootCmd, opts) - AddInstallCmd(rootCmd, &opts) + AddInstallCmd(rootCmd, opts) + AddBuildCmd(rootCmd, opts) AddLicensesCmd(rootCmd) // OMS API key management commands diff --git a/cli/cmd/update.go b/cli/cmd/update.go index e41a8244..ba961958 100644 --- a/cli/cmd/update.go +++ b/cli/cmd/update.go @@ -19,7 +19,7 @@ func (c *UpdateCmd) RunE(_ *cobra.Command, args []string) error { return nil } -func AddUpdateCmd(rootCmd *cobra.Command, opts GlobalOptions) { +func AddUpdateCmd(rootCmd *cobra.Command, opts *GlobalOptions) { updateCmd := UpdateCmd{ cmd: &cobra.Command{ Use: "update", @@ -33,6 +33,7 @@ func AddUpdateCmd(rootCmd *cobra.Command, opts GlobalOptions) { AddDownloadPackageCmd(updateCmd.cmd, opts) AddOmsUpdateCmd(updateCmd.cmd) AddApiKeyUpdateCmd(updateCmd.cmd) + AddUpdateDockerfileCmd(updateCmd.cmd, opts) rootCmd.AddCommand(updateCmd.cmd) } diff --git a/cli/cmd/update_api_key.go b/cli/cmd/update_api_key.go index 44345bf2..fe7afa22 100644 --- a/cli/cmd/update_api_key.go +++ b/cli/cmd/update_api_key.go @@ -18,7 +18,7 @@ type UpdateAPIKeyCmd struct { } type UpdateAPIKeyOpts struct { - GlobalOptions + *GlobalOptions APIKeyID string ExpiresAtStr string } diff --git a/cli/cmd/update_dockerfile.go b/cli/cmd/update_dockerfile.go new file mode 100644 index 00000000..626c4869 --- /dev/null +++ b/cli/cmd/update_dockerfile.go @@ -0,0 +1,123 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "context" + "errors" + "fmt" + "log" + + "github.com/codesphere-cloud/cs-go/pkg/io" + "github.com/codesphere-cloud/oms/internal/env" + "github.com/codesphere-cloud/oms/internal/installer" + "github.com/codesphere-cloud/oms/internal/system" + "github.com/codesphere-cloud/oms/internal/util" + "github.com/spf13/cobra" +) + +type UpdateDockerfileCmd struct { + cmd *cobra.Command + Opts UpdateDockerfileOpts + Env env.Env +} + +type UpdateDockerfileOpts struct { + *GlobalOptions + Package string + Dockerfile string + Baseimage string + Force bool +} + +func (c *UpdateDockerfileCmd) RunE(_ *cobra.Command, args []string) error { + if c.Opts.Package == "" { + return errors.New("required option package not set") + } + + workdir := c.Env.GetOmsWorkdir() + pm := installer.NewPackage(workdir, c.Opts.Package) + im := system.NewImage(context.Background()) + + err := c.UpdateDockerfile(pm, im, args) + if err != nil { + return fmt.Errorf("failed to update dockerfile: %w", err) + } + + return nil +} + +func AddUpdateDockerfileCmd(parentCmd *cobra.Command, opts *GlobalOptions) { + dockerfileCmd := &UpdateDockerfileCmd{ + cmd: &cobra.Command{ + Use: "dockerfile", + Short: "Update FROM statement in Dockerfile with base image from package", + Long: `Update the FROM statement in a Dockerfile to use the base image from a Codesphere package. + +This command extracts the base image from a Codesphere package and updates the FROM statement +in the specified Dockerfile to use that base image. The base image is loaded into the local Docker daemon so it can be used for building.`, + Example: formatExamplesWithBinary("update dockerfile", []io.Example{ + {Cmd: "--dockerfile baseimage/Dockerfile --package codesphere-v1.68.0.tar.gz", Desc: "Update Dockerfile to use the default base image from the package (workspace-agent-24.04)"}, + {Cmd: "--dockerfile baseimage/Dockerfile --package codesphere-v1.68.0.tar.gz --baseimage workspace-agent-20.04.tar", Desc: "Update Dockerfile to use the workspace-agent-20.04 base image from the package"}, + }, "oms-cli"), + Args: cobra.ExactArgs(0), + }, + Opts: UpdateDockerfileOpts{GlobalOptions: opts}, + Env: env.NewEnv(), + } + + dockerfileCmd.cmd.Flags().StringVarP(&dockerfileCmd.Opts.Dockerfile, "dockerfile", "d", "", "Path to the Dockerfile to update (required)") + dockerfileCmd.cmd.Flags().StringVarP(&dockerfileCmd.Opts.Package, "package", "p", "", "Path to the Codesphere package (required)") + dockerfileCmd.cmd.Flags().StringVarP(&dockerfileCmd.Opts.Baseimage, "baseimage", "b", "workspace-agent-24.04", "Name of the base image to use") + dockerfileCmd.cmd.Flags().BoolVarP(&dockerfileCmd.Opts.Force, "force", "f", false, "Force re-extraction of the package") + + util.MarkFlagRequired(dockerfileCmd.cmd, "dockerfile") + util.MarkFlagRequired(dockerfileCmd.cmd, "package") + + parentCmd.AddCommand(dockerfileCmd.cmd) + + dockerfileCmd.cmd.RunE = dockerfileCmd.RunE +} + +func (c *UpdateDockerfileCmd) UpdateDockerfile(pm installer.PackageManager, im system.ImageManager, args []string) error { + imageName, err := pm.GetBaseimageName(c.Opts.Baseimage) + if err != nil { + return fmt.Errorf("failed to get image name: %w", err) + } + + imagePath, err := pm.GetBaseimagePath(c.Opts.Baseimage, c.Opts.Force) + if err != nil { + return fmt.Errorf("failed to get image path: %w", err) + } + + log.Printf("Loading container image from package into local docker daemon: %s", imagePath) + + // Loading image before updating the Dockerfile to ensure it's available for builds + err = im.LoadImage(imagePath) + if err != nil { + return fmt.Errorf("failed to load baseimage file %s: %w", imagePath, err) + } + + // Update dockerfile FROM statement + dockerfileFile, err := pm.FileIO().Open(c.Opts.Dockerfile) + if err != nil { + return fmt.Errorf("failed to open dockerfile %s: %w", c.Opts.Dockerfile, err) + } + defer util.CloseFileIgnoreError(dockerfileFile) + + dockerfileManager := util.NewDockerfileManager() + updatedContent, err := dockerfileManager.UpdateFromStatement(dockerfileFile, imageName) + if err != nil { + return fmt.Errorf("failed to update FROM statement: %w", err) + } + + err = pm.FileIO().WriteFile(c.Opts.Dockerfile, []byte(updatedContent), 0644) + if err != nil { + return fmt.Errorf("failed to write updated dockerfile: %w", err) + } + + log.Printf("Successfully updated FROM statement in %s to use %s", c.Opts.Dockerfile, imageName) + + return nil +} diff --git a/cli/cmd/update_dockerfile_test.go b/cli/cmd/update_dockerfile_test.go new file mode 100644 index 00000000..62c6250c --- /dev/null +++ b/cli/cmd/update_dockerfile_test.go @@ -0,0 +1,315 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd_test + +import ( + "errors" + "os" + + . "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/system" + "github.com/codesphere-cloud/oms/internal/util" +) + +const sampleDockerfileContent = `FROM workspace-agent:20.04 +RUN apt-get update && apt-get install -y curl +WORKDIR /app +COPY . . +CMD ["./start.sh"]` + +var _ = Describe("UpdateDockerfileCmd", func() { + var ( + c cmd.UpdateDockerfileCmd + opts cmd.UpdateDockerfileOpts + globalOpts *cmd.GlobalOptions + mockEnv *env.MockEnv + ) + + BeforeEach(func() { + mockEnv = env.NewMockEnv(GinkgoT()) + globalOpts = &cmd.GlobalOptions{} + opts = cmd.UpdateDockerfileOpts{ + GlobalOptions: globalOpts, + Package: "codesphere-v1.68.0.tar.gz", + Dockerfile: "Dockerfile", + Baseimage: "", + Force: false, + } + c = cmd.UpdateDockerfileCmd{ + Opts: opts, + Env: mockEnv, + } + }) + + AfterEach(func() { + mockEnv.AssertExpectations(GinkgoT()) + }) + + Context("RunE method", func() { + It("fails when package is not set", func() { + c.Opts.Package = "" + + err := c.RunE(nil, []string{}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("required option package not set")) + }) + + It("calls UpdateDockerfile and fails when package manager fails", func() { + mockEnv.EXPECT().GetOmsWorkdir().Return("/test/workdir") + + err := c.RunE(nil, []string{}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to update dockerfile")) + }) + + It("succeeds with valid options", func() { + mockEnv.EXPECT().GetOmsWorkdir().Return("/test/workdir") + + // This will fail in real scenario because it tries to extract from real package + // But it should at least call the correct methods + err := c.RunE(nil, []string{}) + Expect(err).To(HaveOccurred()) + }) + }) + + Context("UpdateDockerfile method", func() { + It("fails when package manager fails to get image path and name", func() { + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) + mockImageManager := system.NewMockImageManager(GinkgoT()) + + c.Opts.Baseimage = "workspace-agent-24.04.tar" + c.Opts.Force = false + + mockPackageManager.EXPECT().GetBaseimageName("workspace-agent-24.04.tar").Return("", errors.New("failed to extract image")) + + err := c.UpdateDockerfile(mockPackageManager, mockImageManager, []string{}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to get image name")) + }) + + It("fails when dockerfile cannot be opened", func() { + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) + mockImageManager := system.NewMockImageManager(GinkgoT()) + mockFileIO := util.NewMockFileIO(GinkgoT()) + + c.Opts.Dockerfile = "Dockerfile" + c.Opts.Baseimage = "" + c.Opts.Force = false + + mockPackageManager.EXPECT().GetBaseimageName("").Return("ubuntu:24.04", nil) + mockPackageManager.EXPECT().GetBaseimagePath("", false).Return("/test/workdir/deps/codesphere/images/workspace-agent-24.04.tar", nil) + mockImageManager.EXPECT().LoadImage("/test/workdir/deps/codesphere/images/workspace-agent-24.04.tar").Return(nil) + mockPackageManager.EXPECT().FileIO().Return(mockFileIO) + mockFileIO.EXPECT().Open("Dockerfile").Return(nil, errors.New("file not found")) + + err := c.UpdateDockerfile(mockPackageManager, mockImageManager, []string{}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to open dockerfile")) + }) + + It("fails when image manager fails to load image", func() { + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) + mockImageManager := system.NewMockImageManager(GinkgoT()) + + c.Opts.Dockerfile = "Dockerfile" + c.Opts.Baseimage = "" + c.Opts.Force = false + + mockPackageManager.EXPECT().GetBaseimageName("").Return("ubuntu:24.04", nil) + mockPackageManager.EXPECT().GetBaseimagePath("", false).Return("/test/workdir/deps/codesphere/images/workspace-agent-24.04.tar", nil) + mockImageManager.EXPECT().LoadImage("/test/workdir/deps/codesphere/images/workspace-agent-24.04.tar").Return(errors.New("load failed")) + + err := c.UpdateDockerfile(mockPackageManager, mockImageManager, []string{}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to load baseimage file")) + }) + + It("fails when writing updated dockerfile fails", func() { + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) + mockImageManager := system.NewMockImageManager(GinkgoT()) + mockFileIO := util.NewMockFileIO(GinkgoT()) + + // Create a temporary file for the Dockerfile + tempFile, err := os.CreateTemp("", "dockerfile-test-*") + Expect(err).To(BeNil()) + DeferCleanup(func() { + _ = tempFile.Close() + _ = os.Remove(tempFile.Name()) + }) + _, err = tempFile.WriteString(sampleDockerfileContent) + Expect(err).To(BeNil()) + // Reset file position to beginning + _, _ = tempFile.Seek(0, 0) + + c.Opts.Dockerfile = "Dockerfile" + c.Opts.Baseimage = "" + c.Opts.Force = false + + mockPackageManager.EXPECT().GetBaseimageName("").Return("ubuntu:24.04", nil) + mockPackageManager.EXPECT().GetBaseimagePath("", false).Return("/test/workdir/deps/codesphere/images/workspace-agent-24.04.tar", nil) + mockImageManager.EXPECT().LoadImage("/test/workdir/deps/codesphere/images/workspace-agent-24.04.tar").Return(nil) + mockPackageManager.EXPECT().FileIO().Return(mockFileIO) + mockFileIO.EXPECT().Open("Dockerfile").Return(tempFile, nil) + mockFileIO.EXPECT().WriteFile("Dockerfile", []byte("FROM ubuntu:24.04\nRUN apt-get update && apt-get install -y curl\nWORKDIR /app\nCOPY . .\nCMD [\"./start.sh\"]"), os.FileMode(0644)).Return(errors.New("write failed")) + + err = c.UpdateDockerfile(mockPackageManager, mockImageManager, []string{}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to write updated dockerfile")) + }) + + It("successfully updates dockerfile and loads image", func() { + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) + mockImageManager := system.NewMockImageManager(GinkgoT()) + mockFileIO := util.NewMockFileIO(GinkgoT()) + + // Create a temporary file for the Dockerfile + tempFile, err := os.CreateTemp("", "dockerfile-test-*") + Expect(err).To(BeNil()) + DeferCleanup(func() { + _ = tempFile.Close() + _ = os.Remove(tempFile.Name()) + }) + _, err = tempFile.WriteString(sampleDockerfileContent) + Expect(err).To(BeNil()) + // Reset file position to beginning + _, _ = tempFile.Seek(0, 0) + + c.Opts.Dockerfile = "Dockerfile" + c.Opts.Baseimage = "" + c.Opts.Force = false + + mockPackageManager.EXPECT().GetBaseimageName("").Return("ubuntu:24.04", nil) + mockPackageManager.EXPECT().GetBaseimagePath("", false).Return("/test/workdir/deps/codesphere/images/workspace-agent-24.04.tar", nil) + mockPackageManager.EXPECT().FileIO().Return(mockFileIO) + mockFileIO.EXPECT().Open("Dockerfile").Return(tempFile, nil) + mockFileIO.EXPECT().WriteFile("Dockerfile", []byte("FROM ubuntu:24.04\nRUN apt-get update && apt-get install -y curl\nWORKDIR /app\nCOPY . .\nCMD [\"./start.sh\"]"), os.FileMode(0644)).Return(nil) + mockImageManager.EXPECT().LoadImage("/test/workdir/deps/codesphere/images/workspace-agent-24.04.tar").Return(nil) + + err = c.UpdateDockerfile(mockPackageManager, mockImageManager, []string{}) + Expect(err).To(BeNil()) + }) + + It("uses force flag when extracting dependencies", func() { + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) + mockImageManager := system.NewMockImageManager(GinkgoT()) + mockFileIO := util.NewMockFileIO(GinkgoT()) + + // Create a temporary file for the Dockerfile + tempFile, err := os.CreateTemp("", "dockerfile-test-*") + Expect(err).To(BeNil()) + DeferCleanup(func() { + _ = tempFile.Close() + _ = os.Remove(tempFile.Name()) + }) + _, err = tempFile.WriteString(sampleDockerfileContent) + Expect(err).To(BeNil()) + // Reset file position to beginning + _, _ = tempFile.Seek(0, 0) + + c.Opts.Dockerfile = "Dockerfile" + c.Opts.Baseimage = "workspace-agent-20.04.tar" + c.Opts.Force = true + + mockPackageManager.EXPECT().GetBaseimageName("workspace-agent-20.04.tar").Return("ubuntu:20.04", nil) + mockPackageManager.EXPECT().GetBaseimagePath("workspace-agent-20.04.tar", true).Return("/test/workdir/deps/codesphere/images/workspace-agent-20.04.tar", nil) + mockPackageManager.EXPECT().FileIO().Return(mockFileIO) + mockFileIO.EXPECT().Open("Dockerfile").Return(tempFile, nil) + mockFileIO.EXPECT().WriteFile("Dockerfile", []byte("FROM ubuntu:20.04\nRUN apt-get update && apt-get install -y curl\nWORKDIR /app\nCOPY . .\nCMD [\"./start.sh\"]"), os.FileMode(0644)).Return(nil) + mockImageManager.EXPECT().LoadImage("/test/workdir/deps/codesphere/images/workspace-agent-20.04.tar").Return(nil) + + err = c.UpdateDockerfile(mockPackageManager, mockImageManager, []string{}) + Expect(err).To(BeNil()) + }) + + It("handles different base image names correctly", func() { + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) + mockImageManager := system.NewMockImageManager(GinkgoT()) + mockFileIO := util.NewMockFileIO(GinkgoT()) + + // Create a temporary file for the Dockerfile + tempFile, err := os.CreateTemp("", "dockerfile-test-*") + Expect(err).To(BeNil()) + DeferCleanup(func() { + _ = tempFile.Close() + _ = os.Remove(tempFile.Name()) + }) + _, err = tempFile.WriteString(sampleDockerfileContent) + Expect(err).To(BeNil()) + // Reset file position to beginning + _, _ = tempFile.Seek(0, 0) + + c.Opts.Dockerfile = "custom/Dockerfile" + c.Opts.Baseimage = "workspace-agent-24.04.tar" + c.Opts.Force = false + + mockPackageManager.EXPECT().GetBaseimageName("workspace-agent-24.04.tar").Return("registry.example.com/workspace-agent:24.04", nil) + mockPackageManager.EXPECT().GetBaseimagePath("workspace-agent-24.04.tar", false).Return("/test/workdir/deps/codesphere/images/workspace-agent-24.04.tar", nil) + mockPackageManager.EXPECT().FileIO().Return(mockFileIO) + mockFileIO.EXPECT().Open("custom/Dockerfile").Return(tempFile, nil) + mockFileIO.EXPECT().WriteFile("custom/Dockerfile", []byte("FROM registry.example.com/workspace-agent:24.04\nRUN apt-get update && apt-get install -y curl\nWORKDIR /app\nCOPY . .\nCMD [\"./start.sh\"]"), os.FileMode(0644)).Return(nil) + mockImageManager.EXPECT().LoadImage("/test/workdir/deps/codesphere/images/workspace-agent-24.04.tar").Return(nil) + + err = c.UpdateDockerfile(mockPackageManager, mockImageManager, []string{}) + Expect(err).To(BeNil()) + }) + }) +}) + +var _ = Describe("AddUpdateDockerfileCmd", func() { + var ( + parentCmd *cobra.Command + globalOpts *cmd.GlobalOptions + ) + + BeforeEach(func() { + parentCmd = &cobra.Command{Use: "update"} + globalOpts = &cmd.GlobalOptions{} + }) + + It("adds the dockerfile command with correct properties and flags", func() { + cmd.AddUpdateDockerfileCmd(parentCmd, globalOpts) + + var dockerfileCmd *cobra.Command + for _, c := range parentCmd.Commands() { + if c.Use == "dockerfile" { + dockerfileCmd = c + break + } + } + + Expect(dockerfileCmd).NotTo(BeNil()) + Expect(dockerfileCmd.Use).To(Equal("dockerfile")) + Expect(dockerfileCmd.Short).To(Equal("Update FROM statement in Dockerfile with base image from package")) + Expect(dockerfileCmd.Long).To(ContainSubstring("Update the FROM statement in a Dockerfile")) + Expect(dockerfileCmd.Long).To(ContainSubstring("base image from a Codesphere package")) + Expect(dockerfileCmd.RunE).NotTo(BeNil()) + + // Check required flags + dockerfileFlag := dockerfileCmd.Flags().Lookup("dockerfile") + Expect(dockerfileFlag).NotTo(BeNil()) + Expect(dockerfileFlag.Shorthand).To(Equal("d")) + Expect(dockerfileFlag.Usage).To(ContainSubstring("Path to the Dockerfile to update")) + + packageFlag := dockerfileCmd.Flags().Lookup("package") + Expect(packageFlag).NotTo(BeNil()) + Expect(packageFlag.Shorthand).To(Equal("p")) + Expect(packageFlag.Usage).To(ContainSubstring("Path to the Codesphere package")) + + basimageFlag := dockerfileCmd.Flags().Lookup("baseimage") + Expect(basimageFlag).NotTo(BeNil()) + Expect(basimageFlag.Shorthand).To(Equal("b")) + Expect(basimageFlag.Usage).To(ContainSubstring("Name of the base image to use")) + + forceFlag := dockerfileCmd.Flags().Lookup("force") + Expect(forceFlag).NotTo(BeNil()) + Expect(forceFlag.Shorthand).To(Equal("f")) + Expect(forceFlag.Usage).To(ContainSubstring("Force re-extraction of the package")) + }) +}) diff --git a/docs/README.md b/docs/README.md index b0693163..c1b30490 100644 --- a/docs/README.md +++ b/docs/README.md @@ -18,6 +18,7 @@ like downloading new versions. ### SEE ALSO * [oms-cli beta](oms-cli_beta.md) - Commands for early testing +* [oms-cli build](oms-cli_build.md) - Build and push images to a registry * [oms-cli download](oms-cli_download.md) - Download resources available through OMS * [oms-cli install](oms-cli_install.md) - Coming soon: Install Codesphere and other components * [oms-cli licenses](oms-cli_licenses.md) - Print license information @@ -27,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 30-Oct-2025 +###### Auto generated by spf13/cobra on 3-Nov-2025 diff --git a/docs/oms-cli.md b/docs/oms-cli.md index b0693163..c1b30490 100644 --- a/docs/oms-cli.md +++ b/docs/oms-cli.md @@ -18,6 +18,7 @@ like downloading new versions. ### SEE ALSO * [oms-cli beta](oms-cli_beta.md) - Commands for early testing +* [oms-cli build](oms-cli_build.md) - Build and push images to a registry * [oms-cli download](oms-cli_download.md) - Download resources available through OMS * [oms-cli install](oms-cli_install.md) - Coming soon: Install Codesphere and other components * [oms-cli licenses](oms-cli_licenses.md) - Print license information @@ -27,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 30-Oct-2025 +###### Auto generated by spf13/cobra on 3-Nov-2025 diff --git a/docs/oms-cli_beta.md b/docs/oms-cli_beta.md index d158f643..1cbccfc1 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 30-Oct-2025 +###### Auto generated by spf13/cobra on 3-Nov-2025 diff --git a/docs/oms-cli_beta_extend.md b/docs/oms-cli_beta_extend.md index e92fb18b..567ebef3 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 30-Oct-2025 +###### Auto generated by spf13/cobra on 3-Nov-2025 diff --git a/docs/oms-cli_beta_extend_baseimage.md b/docs/oms-cli_beta_extend_baseimage.md index 8ba53035..2b2569e0 100644 --- a/docs/oms-cli_beta_extend_baseimage.md +++ b/docs/oms-cli_beta_extend_baseimage.md @@ -17,6 +17,7 @@ oms-cli beta extend baseimage [flags] ### Options ``` + -b, --baseimage string Base image file name inside the package to extend (default: 'workspace-agent-24.04') (default "workspace-agent-24.04") -d, --dockerfile string Output Dockerfile to generate for extending the base image (default "Dockerfile") -f, --force Enforce package extraction -h, --help help for baseimage @@ -27,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 30-Oct-2025 +###### Auto generated by spf13/cobra on 3-Nov-2025 diff --git a/docs/oms-cli_build.md b/docs/oms-cli_build.md new file mode 100644 index 00000000..8f200629 --- /dev/null +++ b/docs/oms-cli_build.md @@ -0,0 +1,21 @@ +## oms-cli build + +Build and push images to a registry + +### Synopsis + +Build and push container images to a registry using the provided configuration. + +### Options + +``` + -h, --help help for build +``` + +### SEE ALSO + +* [oms-cli](oms-cli.md) - Codesphere Operations Management System (OMS) +* [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 diff --git a/docs/oms-cli_build_image.md b/docs/oms-cli_build_image.md new file mode 100644 index 00000000..0964fae6 --- /dev/null +++ b/docs/oms-cli_build_image.md @@ -0,0 +1,34 @@ +## oms-cli build image + +Build and push Docker image using Dockerfile and Codesphere package version + +### Synopsis + +Build a Docker image from a Dockerfile and push it to a registry, tagged with the Codesphere version from the package. + +``` +oms-cli build image [flags] +``` + +### Examples + +``` +# Build image for Codesphere version 1.68.0 and push to specified registry +$ oms-cli build image --dockerfile baseimage/Dockerfile --package codesphere-v1.68.0.tar.gz --registry my-registry.com/my-image + +``` + +### Options + +``` + -d, --dockerfile string Path to the Dockerfile to build (required) + -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) +``` + +### SEE ALSO + +* [oms-cli build](oms-cli_build.md) - Build and push images to a registry + +###### Auto generated by spf13/cobra on 3-Nov-2025 diff --git a/docs/oms-cli_build_images.md b/docs/oms-cli_build_images.md new file mode 100644 index 00000000..db98c6de --- /dev/null +++ b/docs/oms-cli_build_images.md @@ -0,0 +1,25 @@ +## oms-cli build images + +Build and push container images + +### Synopsis + +Build and push container images based on the configuration file. +Extracts necessary image configurations from the provided install config and the downloaded package. + +``` +oms-cli build images [flags] +``` + +### Options + +``` + -c, --config string Path to the configuration YAML file + -h, --help help for images +``` + +### SEE ALSO + +* [oms-cli build](oms-cli_build.md) - Build and push images to a registry + +###### Auto generated by spf13/cobra on 3-Nov-2025 diff --git a/docs/oms-cli_download.md b/docs/oms-cli_download.md index e2655f03..c34ab2e9 100644 --- a/docs/oms-cli_download.md +++ b/docs/oms-cli_download.md @@ -18,4 +18,4 @@ e.g. available Codesphere packages * [oms-cli](oms-cli.md) - Codesphere Operations Management System (OMS) * [oms-cli download package](oms-cli_download_package.md) - Download a codesphere package -###### Auto generated by spf13/cobra on 30-Oct-2025 +###### Auto generated by spf13/cobra on 3-Nov-2025 diff --git a/docs/oms-cli_download_package.md b/docs/oms-cli_download_package.md index 2c24b4fe..b9e323bf 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 30-Oct-2025 +###### Auto generated by spf13/cobra on 3-Nov-2025 diff --git a/docs/oms-cli_install.md b/docs/oms-cli_install.md index a8e5baea..c3530a5b 100644 --- a/docs/oms-cli_install.md +++ b/docs/oms-cli_install.md @@ -17,4 +17,4 @@ 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 -###### Auto generated by spf13/cobra on 30-Oct-2025 +###### Auto generated by spf13/cobra on 3-Nov-2025 diff --git a/docs/oms-cli_install_codesphere.md b/docs/oms-cli_install_codesphere.md index 77840aad..716f2c53 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 30-Oct-2025 +###### Auto generated by spf13/cobra on 3-Nov-2025 diff --git a/docs/oms-cli_licenses.md b/docs/oms-cli_licenses.md index b154ade1..23d01f97 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 30-Oct-2025 +###### Auto generated by spf13/cobra on 3-Nov-2025 diff --git a/docs/oms-cli_list.md b/docs/oms-cli_list.md index bb373a01..3bb421d5 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 30-Oct-2025 +###### Auto generated by spf13/cobra on 3-Nov-2025 diff --git a/docs/oms-cli_list_api-keys.md b/docs/oms-cli_list_api-keys.md index 39ec59cf..02274e0b 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 30-Oct-2025 +###### Auto generated by spf13/cobra on 3-Nov-2025 diff --git a/docs/oms-cli_list_packages.md b/docs/oms-cli_list_packages.md index 8aa6b561..9cb4e521 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 30-Oct-2025 +###### Auto generated by spf13/cobra on 3-Nov-2025 diff --git a/docs/oms-cli_register.md b/docs/oms-cli_register.md index d9952934..7e7b93ce 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 30-Oct-2025 +###### Auto generated by spf13/cobra on 3-Nov-2025 diff --git a/docs/oms-cli_revoke.md b/docs/oms-cli_revoke.md index 56ce2757..cac7846d 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 30-Oct-2025 +###### Auto generated by spf13/cobra on 3-Nov-2025 diff --git a/docs/oms-cli_revoke_api-key.md b/docs/oms-cli_revoke_api-key.md index 49a2c28e..a2746f85 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 30-Oct-2025 +###### Auto generated by spf13/cobra on 3-Nov-2025 diff --git a/docs/oms-cli_update.md b/docs/oms-cli_update.md index 661cc761..f8b93b90 100644 --- a/docs/oms-cli_update.md +++ b/docs/oms-cli_update.md @@ -20,7 +20,8 @@ oms-cli update [flags] * [oms-cli](oms-cli.md) - Codesphere Operations Management System (OMS) * [oms-cli update api-key](oms-cli_update_api-key.md) - Update an API key's expiration date +* [oms-cli update dockerfile](oms-cli_update_dockerfile.md) - Update FROM statement in Dockerfile with base image from package * [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 30-Oct-2025 +###### Auto generated by spf13/cobra on 3-Nov-2025 diff --git a/docs/oms-cli_update_api-key.md b/docs/oms-cli_update_api-key.md index 3ffecbd7..8d811f06 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 30-Oct-2025 +###### Auto generated by spf13/cobra on 3-Nov-2025 diff --git a/docs/oms-cli_update_dockerfile.md b/docs/oms-cli_update_dockerfile.md new file mode 100644 index 00000000..57658229 --- /dev/null +++ b/docs/oms-cli_update_dockerfile.md @@ -0,0 +1,41 @@ +## oms-cli update dockerfile + +Update FROM statement in Dockerfile with base image from package + +### Synopsis + +Update the FROM statement in a Dockerfile to use the base image from a Codesphere package. + +This command extracts the base image from a Codesphere package and updates the FROM statement +in the specified Dockerfile to use that base image. The base image is loaded into the local Docker daemon so it can be used for building. + +``` +oms-cli update dockerfile [flags] +``` + +### Examples + +``` +# Update Dockerfile to use the default base image from the package (workspace-agent-24.04) +$ oms-cli update dockerfile --dockerfile baseimage/Dockerfile --package codesphere-v1.68.0.tar.gz + +# Update Dockerfile to use the workspace-agent-20.04 base image from the package +$ oms-cli update dockerfile --dockerfile baseimage/Dockerfile --package codesphere-v1.68.0.tar.gz --baseimage workspace-agent-20.04.tar + +``` + +### Options + +``` + -b, --baseimage string Name of the base image to use (default "workspace-agent-24.04") + -d, --dockerfile string Path to the Dockerfile to update (required) + -f, --force Force re-extraction of the package + -h, --help help for dockerfile + -p, --package string Path to the Codesphere package (required) +``` + +### SEE ALSO + +* [oms-cli update](oms-cli_update.md) - Update OMS related resources + +###### Auto generated by spf13/cobra on 3-Nov-2025 diff --git a/docs/oms-cli_update_oms.md b/docs/oms-cli_update_oms.md index 75faa045..9d36592f 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 30-Oct-2025 +###### Auto generated by spf13/cobra on 3-Nov-2025 diff --git a/docs/oms-cli_update_package.md b/docs/oms-cli_update_package.md index 92a8a41c..9d2d28b0 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 30-Oct-2025 +###### Auto generated by spf13/cobra on 3-Nov-2025 diff --git a/docs/oms-cli_version.md b/docs/oms-cli_version.md index 24d6c59a..bea79035 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 30-Oct-2025 +###### Auto generated by spf13/cobra on 3-Nov-2025 diff --git a/go.mod b/go.mod index f7bd5b1e..044d88d4 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 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -34,7 +35,6 @@ require ( github.com/stretchr/objx v0.5.2 // indirect github.com/tcnksm/go-gitconfig v0.1.2 // indirect github.com/ulikunitz/xz v0.5.15 // indirect - go.uber.org/automaxprocs v1.6.0 // 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 @@ -45,5 +45,4 @@ require ( golang.org/x/text v0.29.0 // indirect golang.org/x/tools v0.37.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 5a27e0f5..85408cef 100644 --- a/go.sum +++ b/go.sum @@ -62,8 +62,6 @@ github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhg github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo/v2 v2.27.1 h1:0LJC8MpUSQnfnp4n/3W3GdlmJP3ENGF0ZPzjQGLPP7s= -github.com/onsi/ginkgo/v2 v2.27.1/go.mod h1:wmy3vCqiBjirARfVhAqFpYt8uvX0yaFe+GudAqqcCqA= github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/gomega v1.4.2/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= @@ -71,8 +69,6 @@ github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= -github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= github.com/rhysd/go-github-selfupdate v1.2.3 h1:iaa+J202f+Nc+A8zi75uccC8Wg3omaM7HDeimXA22Ag= github.com/rhysd/go-github-selfupdate v1.2.3/go.mod h1:mp/N8zj6jFfBQy/XMYoWsmfzxazpPAODuqarmPDe2Rg= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -101,8 +97,6 @@ github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6 github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= -go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= -go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= diff --git a/hack/lima-oms.yaml b/hack/lima-oms.yaml index 93822fc5..8dabfd2b 100644 --- a/hack/lima-oms.yaml +++ b/hack/lima-oms.yaml @@ -50,7 +50,11 @@ provision: curl -fsSL https://get.docker.com | sh systemctl disable --now docker apt-get install -y uidmap dbus-user-session - + + # Install Go and build tools + apt-get update + apt-get install -y golang-go make git + - mode: user script: | #!/bin/bash @@ -59,11 +63,6 @@ provision: # Install Docker in rootless mode dockerd-rootless-setuptool.sh install - # Install Go 1.24 and build tools - sudo apt update - sudo apt install golang-go - sudo apt-get install -y make git - # Set up the OMS project cd /home/user/oms export PATH=$PATH:/usr/local/go/bin diff --git a/internal/installer/config.go b/internal/installer/config.go new file mode 100644 index 00000000..45c96761 --- /dev/null +++ b/internal/installer/config.go @@ -0,0 +1,36 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "fmt" + + "github.com/codesphere-cloud/oms/internal/installer/files" + "github.com/codesphere-cloud/oms/internal/util" +) + +type Config struct { + FileIO util.FileIO +} + +type ConfigManager interface { + ParseConfigYaml(configPath string) (files.RootConfig, error) +} + +func NewConfig() *Config { + return &Config{ + FileIO: &util.FilesystemWriter{}, + } +} + +// ParseConfigYaml reads and parses the configuration YAML file at the given path. +func (c *Config) ParseConfigYaml(configPath string) (files.RootConfig, error) { + var rootConfig files.RootConfig + err := rootConfig.ParseConfig(configPath) + if err != nil { + return rootConfig, fmt.Errorf("failed to parse config.yaml: %w", err) + } + + return rootConfig, nil +} diff --git a/internal/installer/config_test.go b/internal/installer/config_test.go new file mode 100644 index 00000000..5f09f46e --- /dev/null +++ b/internal/installer/config_test.go @@ -0,0 +1,250 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer_test + +import ( + "os" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/codesphere-cloud/oms/internal/installer" + "github.com/codesphere-cloud/oms/internal/util" +) + +var _ = Describe("Config", func() { + var ( + config *installer.Config + ) + + BeforeEach(func() { + config = installer.NewConfig() + }) + + Describe("NewConfig", func() { + It("creates a new Config with FilesystemWriter", func() { + newConfig := installer.NewConfig() + Expect(newConfig).ToNot(BeNil()) + Expect(newConfig.FileIO).ToNot(BeNil()) + Expect(newConfig.FileIO).To(BeAssignableToTypeOf(&util.FilesystemWriter{})) + }) + }) + + Describe("ParseConfigYaml", func() { + Context("when config file exists and is valid", func() { + It("successfully parses the configuration", func() { + tempDir := GinkgoT().TempDir() + tempConfigFile := filepath.Join(tempDir, "config.yaml") + + validConfigContent := ` +registry: + server: "registry.example.com" +codesphere: + deployConfig: + images: + ubuntu-24.04: + name: "ubuntu-24.04" + supportedUntil: "2025-12-31" + flavors: + default: + image: + bomRef: "ubuntu:24.04" + dockerfile: "Dockerfile" +` + err := os.WriteFile(tempConfigFile, []byte(validConfigContent), 0644) + Expect(err).ToNot(HaveOccurred()) + + rootConfig, err := config.ParseConfigYaml(tempConfigFile) + + Expect(err).ToNot(HaveOccurred()) + Expect(rootConfig.Registry.Server).To(Equal("registry.example.com")) + Expect(rootConfig.Codesphere.DeployConfig.Images).To(HaveKey("ubuntu-24.04")) + }) + }) + + Context("when config file does not exist", func() { + It("returns an error", func() { + nonExistentFile := "/path/to/nonexistent/config.yaml" + + _, err := config.ParseConfigYaml(nonExistentFile) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to parse config.yaml")) + }) + }) + + Context("when config file has invalid YAML", func() { + It("returns an error", func() { + tempDir := GinkgoT().TempDir() + tempConfigFile := filepath.Join(tempDir, "invalid-config.yaml") + + invalidConfigContent := ` +registry: + server: "registry.example.com" + username: "testuser" + password: "testpass" +codesphere: + deploy_config: + images: + ubuntu-24.04: + name: "ubuntu-24.04" + supported_until: "2025-12-31" + flavors: + default: + image: + bom_ref: "ubuntu:24.04" + dockerfile: "Dockerfile" +invalid_yaml: [unclosed_bracket +` + err := os.WriteFile(tempConfigFile, []byte(invalidConfigContent), 0644) + Expect(err).ToNot(HaveOccurred()) + + _, err = config.ParseConfigYaml(tempConfigFile) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to parse config.yaml")) + }) + }) + + Context("when config file is empty", func() { + It("returns empty config without error", func() { + tempDir := GinkgoT().TempDir() + tempConfigFile := filepath.Join(tempDir, "empty-config.yaml") + + err := os.WriteFile(tempConfigFile, []byte(""), 0644) + Expect(err).ToNot(HaveOccurred()) + + rootConfig, err := config.ParseConfigYaml(tempConfigFile) + + Expect(err).ToNot(HaveOccurred()) + Expect(rootConfig.Registry.Server).To(BeEmpty()) + Expect(rootConfig.Codesphere.DeployConfig.Images).To(BeEmpty()) + }) + }) + }) + + Describe("ConfigManager interface", func() { + It("implements ConfigManager interface", func() { + var configManager installer.ConfigManager = config + Expect(configManager).ToNot(BeNil()) + }) + + It("has all required methods", func() { + var configManager installer.ConfigManager = config + + // Test that methods exist by calling them with invalid parameters + // and checking that we get errors (proving the methods are callable) + _, err1 := configManager.ParseConfigYaml("/nonexistent/path") + Expect(err1).To(HaveOccurred()) + }) + }) + + Describe("Config struct fields", func() { + It("has FileIO field", func() { + Expect(config.FileIO).ToNot(BeNil()) + }) + + It("allows FileIO to be replaced with mock", func() { + mockFileIO := util.NewMockFileIO(GinkgoT()) + config.FileIO = mockFileIO + Expect(config.FileIO).To(Equal(mockFileIO)) + }) + }) + + Describe("Error handling and edge cases", func() { + Context("ParseConfigYaml with various file permissions", func() { + It("handles file with read permissions", func() { + tempDir := GinkgoT().TempDir() + tempConfigFile := filepath.Join(tempDir, "config.yaml") + + validConfigContent := ` +registry: + server: "registry.example.com" +` + err := os.WriteFile(tempConfigFile, []byte(validConfigContent), 0644) + Expect(err).ToNot(HaveOccurred()) + + _, err = config.ParseConfigYaml(tempConfigFile) + 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() { + Context("full workflow simulation", func() { + It("can parse complex configuration successfully", func() { + tempDir := GinkgoT().TempDir() + configFile := filepath.Join(tempDir, "config.yaml") + + // Create a realistic config file + configContent := ` +registry: + server: "my-registry.example.com" +codesphere: + deployConfig: + images: + ubuntu-24.04: + name: "ubuntu-24.04" + supportedUntil: "2025-12-31" + flavors: + default: + image: + bomRef: "ubuntu:24.04" + dockerfile: "Dockerfile" + minimal: + image: + bomRef: "ubuntu:24.04-minimal" + dockerfile: "Dockerfile.minimal" + alpine-3.18: + name: "alpine-3.18" + supportedUntil: "2024-12-31" + flavors: + default: + image: + bomRef: "alpine:3.18" + dockerfile: "Dockerfile.alpine" +` + err := os.WriteFile(configFile, []byte(configContent), 0644) + Expect(err).ToNot(HaveOccurred()) + + rootConfig, err := config.ParseConfigYaml(configFile) + + Expect(err).ToNot(HaveOccurred()) + Expect(rootConfig.Registry.Server).To(Equal("my-registry.example.com")) + + // Check images + Expect(rootConfig.Codesphere.DeployConfig.Images).To(HaveLen(2)) + Expect(rootConfig.Codesphere.DeployConfig.Images).To(HaveKey("ubuntu-24.04")) + Expect(rootConfig.Codesphere.DeployConfig.Images).To(HaveKey("alpine-3.18")) + + // Check ubuntu image details + ubuntuImage := rootConfig.Codesphere.DeployConfig.Images["ubuntu-24.04"] + Expect(ubuntuImage.Name).To(Equal("ubuntu-24.04")) + Expect(ubuntuImage.SupportedUntil).To(Equal("2025-12-31")) + Expect(ubuntuImage.Flavors).To(HaveLen(2)) + Expect(ubuntuImage.Flavors).To(HaveKey("default")) + Expect(ubuntuImage.Flavors).To(HaveKey("minimal")) + + // Check alpine image details + alpineImage := rootConfig.Codesphere.DeployConfig.Images["alpine-3.18"] + Expect(alpineImage.Name).To(Equal("alpine-3.18")) + Expect(alpineImage.SupportedUntil).To(Equal("2024-12-31")) + Expect(alpineImage.Flavors).To(HaveLen(1)) + Expect(alpineImage.Flavors).To(HaveKey("default")) + }) + }) + }) +}) diff --git a/internal/installer/files/bom_json.go b/internal/installer/files/bom_json.go new file mode 100644 index 00000000..91ab8894 --- /dev/null +++ b/internal/installer/files/bom_json.go @@ -0,0 +1,83 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package files + +import ( + "encoding/json" + "fmt" + "os" +) + +// BomConfig represents the Bill of Materials configuration +type BomConfig struct { + Components map[string]ComponentConfig `json:"components"` + Migrations MigrationsConfig `json:"migrations"` +} + +// ComponentConfig represents a component in the BOM +type ComponentConfig struct { + ContainerImages map[string]string `json:"containerImages,omitempty"` + Files map[string]FileRef `json:"files,omitempty"` +} + +// FileRef represents a file reference in the BOM +type FileRef struct { + SrcPath string `json:"srcPath,omitempty"` + SrcUrl string `json:"srcUrl,omitempty"` + Executable bool `json:"executable,omitempty"` + Glob *GlobRef `json:"glob,omitempty"` +} + +// GlobRef represents a glob-based file reference +type GlobRef struct { + Cwd string `json:"cwd"` + Include string `json:"include"` + Exclude []string `json:"exclude,omitempty"` +} + +// MigrationsConfig represents the migrations configuration +type MigrationsConfig struct { + Db DbMigrationConfig `json:"db"` +} + +// DbMigrationConfig represents database migration configuration +type DbMigrationConfig struct { + Path string `json:"path"` + From string `json:"from"` +} + +// CodesphereComponent represents the codesphere-specific component +type CodesphereComponent struct { + ContainerImages map[string]string `json:"containerImages"` + Files map[string]FileRef `json:"files"` +} + +// ParseBomConfig reads and parses a BOM JSON file +func (b *BomConfig) ParseBomConfig(filePath string) error { + bomData, err := os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("failed to read BOM file: %w", err) + } + + err = json.Unmarshal(bomData, b) + if err != nil { + return fmt.Errorf("failed to parse JSON BOM: %w", err) + } + + return nil +} + +// GetCodesphereContainerImages returns all container images from the codesphere component +func (b *BomConfig) GetCodesphereContainerImages() (map[string]string, error) { + if b.Components == nil { + return nil, fmt.Errorf("codesphere component not found in BOM") + } + + codesphereComp, exists := b.Components["codesphere"] + if !exists { + return nil, fmt.Errorf("codesphere component not found in BOM") + } + + return codesphereComp.ContainerImages, nil +} diff --git a/internal/installer/files/bom_json_test.go b/internal/installer/files/bom_json_test.go new file mode 100644 index 00000000..ea2361f1 --- /dev/null +++ b/internal/installer/files/bom_json_test.go @@ -0,0 +1,226 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package files_test + +import ( + "encoding/json" + "os" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/codesphere-cloud/oms/internal/installer/files" +) + +var _ = Describe("BomJson", func() { + var ( + bomConfig *files.BomConfig + tempDir string + bomFile string + sampleBom map[string]interface{} + ) + + BeforeEach(func() { + bomConfig = &files.BomConfig{} + + var err error + tempDir, err = os.MkdirTemp("", "bom_test") + Expect(err).NotTo(HaveOccurred()) + + bomFile = filepath.Join(tempDir, "bom.json") + + // Create sample BOM data matching the real structure + sampleBom = map[string]interface{}{ + "components": map[string]interface{}{ + "codesphere": map[string]interface{}{ + "containerImages": map[string]interface{}{ + "workspace-agent-24.04": "ghcr.io/codesphere-cloud/codesphere-monorepo/workspace-agent-24.04:codesphere-v1.66.0", + "workspace-agent-20.04": "ghcr.io/codesphere-cloud/codesphere-monorepo/workspace-agent-20.04:codesphere-v1.66.0", + "auth-service": "ghcr.io/codesphere-cloud/codesphere-monorepo/auth-service:codesphere-v1.66.0", + "ide-service": "ghcr.io/codesphere-cloud/codesphere-monorepo/ide-service:codesphere-v1.66.0", + "workspace-service": "ghcr.io/codesphere-cloud/codesphere-monorepo/workspace-service:codesphere-v1.66.0", + "nginx": "ghcr.io/codesphere-cloud/docker/nginx:1.26.3", + }, + "files": map[string]interface{}{ + "chart": map[string]interface{}{ + "glob": map[string]interface{}{ + "cwd": "helm/codesphere", + "include": "**/*", + "exclude": []string{"*.json5", "values-*.yaml"}, + }, + }, + "schemaDump": map[string]interface{}{ + "srcPath": "infra/bin/private-cloud/pg-masterdata.sql", + }, + }, + }, + "docker": map[string]interface{}{ + "files": map[string]interface{}{ + "24.04_containerd": map[string]interface{}{ + "srcUrl": "https://download.docker.com/linux/ubuntu/dists/noble/pool/stable/amd64/containerd.io_1.6.31-1_amd64.deb", + "executable": false, + }, + }, + }, + "kubernetes": map[string]interface{}{ + "files": map[string]interface{}{ + "k0s": map[string]interface{}{ + "srcUrl": "https://github.com/k0sproject/k0s/releases/download/v1.28.4%2Bk0s.0/k0s-v1.28.4+k0s.0-amd64", + "executable": true, + }, + }, + }, + }, + "migrations": map[string]interface{}{ + "db": map[string]interface{}{ + "path": "packages/migrations/released", + "from": "0.0.1", + }, + }, + } + }) + + AfterEach(func() { + _ = os.RemoveAll(tempDir) + }) + + Describe("ParseBomConfig", func() { + It("should parse a valid BOM file successfully", func() { + // Write sample BOM to file + bomData, err := json.Marshal(sampleBom) + Expect(err).NotTo(HaveOccurred()) + + err = os.WriteFile(bomFile, bomData, 0644) + Expect(err).NotTo(HaveOccurred()) + + err = bomConfig.ParseBomConfig(bomFile) + Expect(err).NotTo(HaveOccurred()) + + Expect(bomConfig.Components).To(HaveKey("codesphere")) + Expect(bomConfig.Components).To(HaveKey("docker")) + Expect(bomConfig.Components).To(HaveKey("kubernetes")) + Expect(bomConfig.Migrations.Db.Path).To(Equal("packages/migrations/released")) + Expect(bomConfig.Migrations.Db.From).To(Equal("0.0.1")) + }) + + It("should return error for non-existent file", func() { + err := bomConfig.ParseBomConfig("/non/existent/file.json") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to read BOM file")) + }) + + It("should return error for invalid JSON", func() { + err := os.WriteFile(bomFile, []byte("invalid json content"), 0644) + Expect(err).NotTo(HaveOccurred()) + + err = bomConfig.ParseBomConfig(bomFile) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to parse JSON BOM")) + }) + + It("should handle empty BOM file", func() { + err := os.WriteFile(bomFile, []byte("{}"), 0644) + Expect(err).NotTo(HaveOccurred()) + + err = bomConfig.ParseBomConfig(bomFile) + Expect(err).NotTo(HaveOccurred()) + + Expect(bomConfig.Components).To(BeEmpty()) + }) + }) + + Describe("GetCodesphereContainerImages", func() { + BeforeEach(func() { + bomData, err := json.Marshal(sampleBom) + Expect(err).NotTo(HaveOccurred()) + + err = os.WriteFile(bomFile, bomData, 0644) + Expect(err).NotTo(HaveOccurred()) + + err = bomConfig.ParseBomConfig(bomFile) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should return all codesphere container images", func() { + images, err := bomConfig.GetCodesphereContainerImages() + Expect(err).NotTo(HaveOccurred()) + Expect(images).NotTo(BeNil()) + + Expect(images).To(HaveKey("workspace-agent-24.04")) + Expect(images).To(HaveKey("workspace-agent-20.04")) + Expect(images).To(HaveKey("auth-service")) + Expect(images).To(HaveKey("ide-service")) + Expect(images).To(HaveKey("workspace-service")) + Expect(images).To(HaveKey("nginx")) + + Expect(images["workspace-agent-24.04"]).To(Equal("ghcr.io/codesphere-cloud/codesphere-monorepo/workspace-agent-24.04:codesphere-v1.66.0")) + Expect(images["auth-service"]).To(Equal("ghcr.io/codesphere-cloud/codesphere-monorepo/auth-service:codesphere-v1.66.0")) + + // Should have 6 images in our test data + Expect(len(images)).To(Equal(6)) + }) + + It("should return error when codesphere component is missing", func() { + // Create a fresh bomConfig for this test to avoid state from BeforeEach + freshBomConfig := &files.BomConfig{} + + // Create BOM without codesphere component + bomWithoutCodesphere := map[string]interface{}{ + "components": map[string]interface{}{ + "docker": map[string]interface{}{ + "files": map[string]interface{}{ + "containerd": map[string]interface{}{ + "srcUrl": "https://download.docker.com/test.deb", + }, + }, + }, + }, + } + + bomData, err := json.Marshal(bomWithoutCodesphere) + Expect(err).NotTo(HaveOccurred()) + + err = os.WriteFile(bomFile, bomData, 0644) + Expect(err).NotTo(HaveOccurred()) + + err = freshBomConfig.ParseBomConfig(bomFile) + Expect(err).NotTo(HaveOccurred()) + + _, err = freshBomConfig.GetCodesphereContainerImages() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("codesphere component not found in BOM")) + }) + + It("should handle codesphere component with no container images", func() { + bomWithEmptyCodesphere := map[string]interface{}{ + "components": map[string]interface{}{ + "codesphere": map[string]interface{}{ + "files": map[string]interface{}{ + "chart": map[string]interface{}{ + "glob": map[string]interface{}{ + "cwd": "helm/codesphere", + "include": "**/*", + }, + }, + }, + }, + }, + } + + bomData, err := json.Marshal(bomWithEmptyCodesphere) + Expect(err).NotTo(HaveOccurred()) + + err = os.WriteFile(bomFile, bomData, 0644) + Expect(err).NotTo(HaveOccurred()) + + err = bomConfig.ParseBomConfig(bomFile) + Expect(err).NotTo(HaveOccurred()) + + images, err := bomConfig.GetCodesphereContainerImages() + Expect(err).NotTo(HaveOccurred()) + Expect(images).To(BeEmpty()) + }) + }) +}) diff --git a/internal/installer/files/config_yaml.go b/internal/installer/files/config_yaml.go new file mode 100644 index 00000000..d31eab68 --- /dev/null +++ b/internal/installer/files/config_yaml.go @@ -0,0 +1,84 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package files + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +// RootConfig represents the relevant parts of the configuration file +type RootConfig struct { + Registry RegistryConfig `yaml:"registry"` + Codesphere CodesphereConfig `yaml:"codesphere"` +} + +type RegistryConfig struct { + Server string `yaml:"server"` +} + +type CodesphereConfig struct { + DeployConfig DeployConfig `yaml:"deployConfig"` +} + +type DeployConfig struct { + Images map[string]ImageConfig `yaml:"images"` +} + +type ImageConfig struct { + Name string `yaml:"name"` + SupportedUntil string `yaml:"supportedUntil"` + Flavors map[string]FlavorConfig `yaml:"flavors"` +} + +type FlavorConfig struct { + Image ImageRef `yaml:"image"` + Pool map[int]int `yaml:"pool"` +} + +type ImageRef struct { + BomRef string `yaml:"bomRef"` + Dockerfile string `yaml:"dockerfile"` +} + +func (c *RootConfig) ParseConfig(filePath string) error { + configData, err := os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("failed to read config file: %w", err) + } + + err = yaml.Unmarshal(configData, c) + if err != nil { + return fmt.Errorf("failed to parse YAML config: %w", err) + } + + return nil +} + +func (c *RootConfig) ExtractBomRefs() []string { + var bomRefs []string + for _, imageConfig := range c.Codesphere.DeployConfig.Images { + for _, flavor := range imageConfig.Flavors { + if flavor.Image.BomRef != "" { + bomRefs = append(bomRefs, flavor.Image.BomRef) + } + } + } + + return bomRefs +} + +func (c *RootConfig) ExtractWorkspaceDockerfiles() map[string]string { + dockerfiles := make(map[string]string) + for _, imageConfig := range c.Codesphere.DeployConfig.Images { + for _, flavor := range imageConfig.Flavors { + if flavor.Image.Dockerfile != "" { + dockerfiles[flavor.Image.Dockerfile] = flavor.Image.BomRef + } + } + } + return dockerfiles +} diff --git a/internal/installer/files/config_yaml_test.go b/internal/installer/files/config_yaml_test.go new file mode 100644 index 00000000..4c235a85 --- /dev/null +++ b/internal/installer/files/config_yaml_test.go @@ -0,0 +1,243 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package files_test + +import ( + "os" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/codesphere-cloud/oms/internal/installer/files" +) + +var _ = Describe("ConfigYaml", func() { + var ( + rootConfig *files.RootConfig + tempDir string + configFile string + sampleYaml string + ) + + BeforeEach(func() { + rootConfig = &files.RootConfig{} + + var err error + tempDir, err = os.MkdirTemp("", "config_yaml_test") + Expect(err).NotTo(HaveOccurred()) + + configFile = filepath.Join(tempDir, "config.yaml") + + sampleYaml = `registry: + server: registry.example.com + +codesphere: + deployConfig: + images: + workspace-agent-24.04: + name: ubuntu-24.04 + supportedUntil: "2029-04-01" + flavors: + default: + image: + bomRef: workspace-agent-24.04 + dockerfile: dockerfile-24.04 + pool: + 8: 2 + 16: 1 + minimal: + image: + bomRef: workspace-agent-24.04-minimal + dockerfile: dockerfile-24.04-minimal + pool: + 4: 1 + workspace-agent-20.04: + name: ubuntu-20.04 + supportedUntil: "2025-04-01" + flavors: + default: + image: + bomRef: workspace-agent-20.04 + dockerfile: dockerfile-20.04 + pool: + 8: 1 + ide-service: + name: ide-service + supportedUntil: "2026-01-01" + flavors: + default: + image: + bomRef: ide-service + pool: + 4: 2 +` + }) + + AfterEach(func() { + _ = os.RemoveAll(tempDir) + }) + + Describe("ParseConfig", func() { + It("should parse a valid YAML config file successfully", func() { + err := os.WriteFile(configFile, []byte(sampleYaml), 0644) + Expect(err).NotTo(HaveOccurred()) + + err = rootConfig.ParseConfig(configFile) + Expect(err).NotTo(HaveOccurred()) + + Expect(rootConfig.Registry.Server).To(Equal("registry.example.com")) + Expect(rootConfig.Codesphere.DeployConfig.Images).To(HaveKey("workspace-agent-24.04")) + Expect(rootConfig.Codesphere.DeployConfig.Images).To(HaveKey("workspace-agent-20.04")) + Expect(rootConfig.Codesphere.DeployConfig.Images).To(HaveKey("ide-service")) + + // Check specific image config + workspaceAgent24 := rootConfig.Codesphere.DeployConfig.Images["workspace-agent-24.04"] + Expect(workspaceAgent24.Name).To(Equal("ubuntu-24.04")) + Expect(workspaceAgent24.SupportedUntil).To(Equal("2029-04-01")) + Expect(workspaceAgent24.Flavors).To(HaveKey("default")) + Expect(workspaceAgent24.Flavors).To(HaveKey("minimal")) + + // Check flavor details + defaultFlavor := workspaceAgent24.Flavors["default"] + Expect(defaultFlavor.Image.BomRef).To(Equal("workspace-agent-24.04")) + Expect(defaultFlavor.Image.Dockerfile).To(Equal("dockerfile-24.04")) + Expect(defaultFlavor.Pool).To(HaveKeyWithValue(8, 2)) + Expect(defaultFlavor.Pool).To(HaveKeyWithValue(16, 1)) + }) + + It("should return error for non-existent file", func() { + err := rootConfig.ParseConfig("/non/existent/config.yaml") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to read config file")) + }) + + It("should return error for invalid YAML", func() { + invalidYaml := `registry: + server: registry.example.com +codesphere: + deployConfig: + images: + - invalid: yaml structure without proper mapping +` + err := os.WriteFile(configFile, []byte(invalidYaml), 0644) + Expect(err).NotTo(HaveOccurred()) + + err = rootConfig.ParseConfig(configFile) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to parse YAML config")) + }) + + It("should handle empty config file", func() { + err := os.WriteFile(configFile, []byte(""), 0644) + Expect(err).NotTo(HaveOccurred()) + + err = rootConfig.ParseConfig(configFile) + Expect(err).NotTo(HaveOccurred()) + + Expect(rootConfig.Registry.Server).To(BeEmpty()) + Expect(rootConfig.Codesphere.DeployConfig.Images).To(BeEmpty()) + }) + + It("should handle minimal valid config", func() { + minimalYaml := `registry: + server: minimal.registry.com +codesphere: + deployConfig: + images: {} +` + err := os.WriteFile(configFile, []byte(minimalYaml), 0644) + Expect(err).NotTo(HaveOccurred()) + + err = rootConfig.ParseConfig(configFile) + Expect(err).NotTo(HaveOccurred()) + + Expect(rootConfig.Registry.Server).To(Equal("minimal.registry.com")) + Expect(rootConfig.Codesphere.DeployConfig.Images).To(BeEmpty()) + }) + }) + + Describe("ExtractBomRefs", func() { + BeforeEach(func() { + err := os.WriteFile(configFile, []byte(sampleYaml), 0644) + Expect(err).NotTo(HaveOccurred()) + + err = rootConfig.ParseConfig(configFile) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should extract all BOM references from config", func() { + bomRefs := rootConfig.ExtractBomRefs() + + Expect(bomRefs).NotTo(BeEmpty()) + Expect(bomRefs).To(ContainElement("workspace-agent-24.04")) + Expect(bomRefs).To(ContainElement("workspace-agent-24.04-minimal")) + Expect(bomRefs).To(ContainElement("workspace-agent-20.04")) + Expect(bomRefs).To(ContainElement("ide-service")) + Expect(len(bomRefs)).To(Equal(4)) + }) + + It("should return empty slice when no images are configured", func() { + emptyConfig := &files.RootConfig{} + bomRefs := emptyConfig.ExtractBomRefs() + + Expect(bomRefs).To(BeEmpty()) + }) + + It("should handle flavors without BOM references", func() { + noImagesConfig := &files.RootConfig{} + yamlWithoutBomRefs := `registry: + server: registry.example.com +codesphere: + deployConfig: + images: + test-image: + name: test + flavors: + default: + image: + dockerfile: dockerfile-only + pool: + 4: 1 +` + err := os.WriteFile(configFile, []byte(yamlWithoutBomRefs), 0644) + Expect(err).NotTo(HaveOccurred()) + + err = noImagesConfig.ParseConfig(configFile) + Expect(err).NotTo(HaveOccurred()) + + bomRefs := noImagesConfig.ExtractBomRefs() + Expect(bomRefs).To(BeEmpty()) + }) + }) + + Describe("ExtractWorkspaceDockerfiles", func() { + BeforeEach(func() { + err := os.WriteFile(configFile, []byte(sampleYaml), 0644) + Expect(err).NotTo(HaveOccurred()) + + err = rootConfig.ParseConfig(configFile) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should return empty map when no images are configured", func() { + emptyConfig := &files.RootConfig{} + dockerfiles := emptyConfig.ExtractWorkspaceDockerfiles() + + Expect(dockerfiles).To(BeEmpty()) + }) + + It("should extract all Dockerfile paths mapped to their BOM references", func() { + dockerfiles := rootConfig.ExtractWorkspaceDockerfiles() + + Expect(dockerfiles).NotTo(BeEmpty()) + Expect(dockerfiles).To(HaveKeyWithValue("dockerfile-24.04", "workspace-agent-24.04")) + Expect(dockerfiles).To(HaveKeyWithValue("dockerfile-24.04-minimal", "workspace-agent-24.04-minimal")) + Expect(dockerfiles).To(HaveKeyWithValue("dockerfile-20.04", "workspace-agent-20.04")) + + // Should have 3 dockerfile mappings (ide-service has no dockerfile) + Expect(len(dockerfiles)).To(Equal(3)) + }) + }) +}) diff --git a/internal/installer/files/files_suite_test.go b/internal/installer/files/files_suite_test.go new file mode 100644 index 00000000..b854bdd9 --- /dev/null +++ b/internal/installer/files/files_suite_test.go @@ -0,0 +1,16 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package files_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestFiles(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Files Suite") +} diff --git a/internal/installer/files/oci_image_index.go b/internal/installer/files/oci_image_index.go new file mode 100644 index 00000000..bb9aa5de --- /dev/null +++ b/internal/installer/files/oci_image_index.go @@ -0,0 +1,58 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package files + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/codesphere-cloud/oms/internal/util" +) + +// OCIImageIndex represents the top-level structure of an OCI Image Index (manifest list). +type OCIImageIndex struct { + SchemaVersion int `json:"schemaVersion"` + MediaType string `json:"mediaType"` + Manifests []ManifestEntry `json:"manifests"` +} + +// ManifestEntry represents a single manifest entry within the index. +type ManifestEntry struct { + MediaType string `json:"mediaType"` + Digest string `json:"digest"` + Size int64 `json:"size"` + Annotations map[string]string `json:"annotations,omitempty"` // Use omitempty just in case, though usually present +} + +func (o *OCIImageIndex) ParseOCIImageConfig(filePath string) error { + indexfile := filepath.Join(filepath.Dir(filePath), "index.json") + file, err := os.Open(indexfile) + if err != nil { + return fmt.Errorf("failed to open file %s: %w", indexfile, err) + } + defer util.CloseFileIgnoreError(file) + + decoder := json.NewDecoder(file) + err = decoder.Decode(o) + if err != nil { + return fmt.Errorf("failed to decode file %s: %w", indexfile, err) + } + + return nil +} + +// ExtractImageNames extracts the image names from the OCI image index file. +func (o *OCIImageIndex) ExtractImageNames() ([]string, error) { + var names []string + for _, manifest := range o.Manifests { + name := manifest.Annotations["io.containerd.image.name"] + if name == "" { + continue + } + names = append(names, name) + } + return names, nil +} diff --git a/internal/installer/files/oci_image_index_test.go b/internal/installer/files/oci_image_index_test.go new file mode 100644 index 00000000..a46ede12 --- /dev/null +++ b/internal/installer/files/oci_image_index_test.go @@ -0,0 +1,217 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package files_test + +import ( + "encoding/json" + "os" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/codesphere-cloud/oms/internal/installer/files" +) + +var _ = Describe("OciImageIndex", func() { + var ( + ociIndex *files.OCIImageIndex + tempDir string + indexFile string + sampleIndex map[string]interface{} + ) + + BeforeEach(func() { + ociIndex = &files.OCIImageIndex{} + + var err error + tempDir, err = os.MkdirTemp("", "oci_index_test") + Expect(err).NotTo(HaveOccurred()) + + indexFile = filepath.Join(tempDir, "index.json") + + // Create sample OCI Image Index data matching the real structure + sampleIndex = map[string]interface{}{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.index.v1+json", + "manifests": []interface{}{ + map[string]interface{}{ + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + "size": int64(527), + "annotations": map[string]interface{}{ + "io.containerd.image.name": "ghcr.io/codesphere-cloud/codesphere-monorepo/workspace-agent-24.04:codesphere-v1.66.0", + }, + }, + map[string]interface{}{ + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + "size": int64(842), + "annotations": map[string]interface{}{ + "io.containerd.image.name": "ghcr.io/codesphere-cloud/codesphere-monorepo/workspace-agent-20.04:codesphere-v1.66.0", + }, + }, + map[string]interface{}{ + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321", + "size": int64(1024), + "annotations": map[string]interface{}{ + "io.containerd.image.name": "registry.example.com/auth-service:v1.0.0", + }, + }, + map[string]interface{}{ + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "size": int64(256), + "annotations": map[string]interface{}{ + "org.opencontainers.image.ref.name": "nginx:latest", + "custom.annotation": "some-value", + }, + }, + }, + } + }) + + AfterEach(func() { + _ = os.RemoveAll(tempDir) + }) + + Describe("ParseOCIImageConfig", func() { + It("should parse a valid OCI Image Index file successfully", func() { + // Write sample index to file + indexData, err := json.Marshal(sampleIndex) + Expect(err).NotTo(HaveOccurred()) + + err = os.WriteFile(indexFile, indexData, 0644) + Expect(err).NotTo(HaveOccurred()) + + // The function expects a file path and looks for index.json in the same directory + err = ociIndex.ParseOCIImageConfig(indexFile) + Expect(err).NotTo(HaveOccurred()) + + Expect(ociIndex.SchemaVersion).To(Equal(2)) + Expect(ociIndex.MediaType).To(Equal("application/vnd.oci.image.index.v1+json")) + Expect(ociIndex.Manifests).To(HaveLen(4)) + + // Check first manifest entry + firstManifest := ociIndex.Manifests[0] + Expect(firstManifest.MediaType).To(Equal("application/vnd.oci.image.manifest.v1+json")) + Expect(firstManifest.Digest).To(Equal("sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef")) + Expect(firstManifest.Size).To(Equal(int64(527))) + Expect(firstManifest.Annotations).To(HaveKeyWithValue("io.containerd.image.name", "ghcr.io/codesphere-cloud/codesphere-monorepo/workspace-agent-24.04:codesphere-v1.66.0")) + }) + + It("should return error for non-existent index.json file", func() { + nonExistentFile := filepath.Join(tempDir, "nonexistent.json") + err := ociIndex.ParseOCIImageConfig(nonExistentFile) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to open file")) + Expect(err.Error()).To(ContainSubstring("index.json")) + }) + + It("should return error for invalid JSON in index.json", func() { + err := os.WriteFile(indexFile, []byte("invalid json content"), 0644) + Expect(err).NotTo(HaveOccurred()) + + err = ociIndex.ParseOCIImageConfig(indexFile) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to decode file")) + }) + + It("should handle empty index.json file", func() { + err := os.WriteFile(indexFile, []byte("{}"), 0644) + Expect(err).NotTo(HaveOccurred()) + + err = ociIndex.ParseOCIImageConfig(indexFile) + Expect(err).NotTo(HaveOccurred()) + + Expect(ociIndex.SchemaVersion).To(Equal(0)) + Expect(ociIndex.MediaType).To(BeEmpty()) + Expect(ociIndex.Manifests).To(BeEmpty()) + }) + + It("should handle index.json in subdirectory when given file path in subdirectory", func() { + subDir := filepath.Join(tempDir, "subdir") + err := os.MkdirAll(subDir, 0755) + Expect(err).NotTo(HaveOccurred()) + + subIndexFile := filepath.Join(subDir, "index.json") + someOtherFile := filepath.Join(subDir, "other.json") + + indexData, err := json.Marshal(sampleIndex) + Expect(err).NotTo(HaveOccurred()) + + err = os.WriteFile(subIndexFile, indexData, 0644) + Expect(err).NotTo(HaveOccurred()) + + // Pass the "other.json" file path - function should still find index.json in same directory + err = ociIndex.ParseOCIImageConfig(someOtherFile) + Expect(err).NotTo(HaveOccurred()) + + Expect(ociIndex.SchemaVersion).To(Equal(2)) + Expect(ociIndex.Manifests).To(HaveLen(4)) + }) + }) + + Describe("ExtractImageNames", func() { + BeforeEach(func() { + indexData, err := json.Marshal(sampleIndex) + Expect(err).NotTo(HaveOccurred()) + + err = os.WriteFile(indexFile, indexData, 0644) + Expect(err).NotTo(HaveOccurred()) + + err = ociIndex.ParseOCIImageConfig(indexFile) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should return empty slice when no manifests exist", func() { + emptyIndex := &files.OCIImageIndex{} + names, err := emptyIndex.ExtractImageNames() + Expect(err).NotTo(HaveOccurred()) + Expect(names).To(BeEmpty()) + }) + + It("should handle manifests without annotations", func() { + // Create a fresh ociIndex for this test to avoid state from BeforeEach + freshIndex := &files.OCIImageIndex{} + + indexWithoutAnnotations := map[string]interface{}{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.index.v1+json", + "manifests": []interface{}{ + map[string]interface{}{ + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + "size": int64(527), + }, + }, + } + + indexData, err := json.Marshal(indexWithoutAnnotations) + Expect(err).NotTo(HaveOccurred()) + + err = os.WriteFile(indexFile, indexData, 0644) + Expect(err).NotTo(HaveOccurred()) + + err = freshIndex.ParseOCIImageConfig(indexFile) + Expect(err).NotTo(HaveOccurred()) + + names, err := freshIndex.ExtractImageNames() + Expect(err).NotTo(HaveOccurred()) + Expect(names).To(BeEmpty()) + }) + + It("should extract all image names from manifests with containerd annotations", func() { + names, err := ociIndex.ExtractImageNames() + Expect(err).NotTo(HaveOccurred()) + Expect(names).NotTo(BeEmpty()) + + Expect(names).To(ContainElement("ghcr.io/codesphere-cloud/codesphere-monorepo/workspace-agent-24.04:codesphere-v1.66.0")) + Expect(names).To(ContainElement("ghcr.io/codesphere-cloud/codesphere-monorepo/workspace-agent-20.04:codesphere-v1.66.0")) + Expect(names).To(ContainElement("registry.example.com/auth-service:v1.0.0")) + Expect(len(names)).To(Equal(3)) + }) + }) +}) diff --git a/internal/installer/installer_suite_test.go b/internal/installer/installer_suite_test.go new file mode 100644 index 00000000..b1184b7c --- /dev/null +++ b/internal/installer/installer_suite_test.go @@ -0,0 +1,16 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestInstaller(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Installer Suite") +} diff --git a/internal/installer/mocks.go b/internal/installer/mocks.go new file mode 100644 index 00000000..9779117e --- /dev/null +++ b/internal/installer/mocks.go @@ -0,0 +1,561 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package installer + +import ( + "github.com/codesphere-cloud/oms/internal/installer/files" + "github.com/codesphere-cloud/oms/internal/util" + mock "github.com/stretchr/testify/mock" +) + +// NewMockConfigManager creates a new instance of MockConfigManager. 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 NewMockConfigManager(t interface { + mock.TestingT + Cleanup(func()) +}) *MockConfigManager { + mock := &MockConfigManager{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockConfigManager is an autogenerated mock type for the ConfigManager type +type MockConfigManager struct { + mock.Mock +} + +type MockConfigManager_Expecter struct { + mock *mock.Mock +} + +func (_m *MockConfigManager) EXPECT() *MockConfigManager_Expecter { + return &MockConfigManager_Expecter{mock: &_m.Mock} +} + +// ParseConfigYaml provides a mock function for the type MockConfigManager +func (_mock *MockConfigManager) ParseConfigYaml(configPath string) (files.RootConfig, error) { + ret := _mock.Called(configPath) + + if len(ret) == 0 { + panic("no return value specified for ParseConfigYaml") + } + + var r0 files.RootConfig + var r1 error + if returnFunc, ok := ret.Get(0).(func(string) (files.RootConfig, error)); ok { + return returnFunc(configPath) + } + if returnFunc, ok := ret.Get(0).(func(string) files.RootConfig); ok { + r0 = returnFunc(configPath) + } else { + r0 = ret.Get(0).(files.RootConfig) + } + if returnFunc, ok := ret.Get(1).(func(string) error); ok { + r1 = returnFunc(configPath) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockConfigManager_ParseConfigYaml_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ParseConfigYaml' +type MockConfigManager_ParseConfigYaml_Call struct { + *mock.Call +} + +// ParseConfigYaml is a helper method to define mock.On call +// - configPath +func (_e *MockConfigManager_Expecter) ParseConfigYaml(configPath interface{}) *MockConfigManager_ParseConfigYaml_Call { + return &MockConfigManager_ParseConfigYaml_Call{Call: _e.mock.On("ParseConfigYaml", configPath)} +} + +func (_c *MockConfigManager_ParseConfigYaml_Call) Run(run func(configPath string)) *MockConfigManager_ParseConfigYaml_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockConfigManager_ParseConfigYaml_Call) Return(rootConfig files.RootConfig, err error) *MockConfigManager_ParseConfigYaml_Call { + _c.Call.Return(rootConfig, err) + return _c +} + +func (_c *MockConfigManager_ParseConfigYaml_Call) RunAndReturn(run func(configPath string) (files.RootConfig, error)) *MockConfigManager_ParseConfigYaml_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 { + mock.TestingT + Cleanup(func()) +}) *MockPackageManager { + mock := &MockPackageManager{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockPackageManager is an autogenerated mock type for the PackageManager type +type MockPackageManager struct { + mock.Mock +} + +type MockPackageManager_Expecter struct { + mock *mock.Mock +} + +func (_m *MockPackageManager) EXPECT() *MockPackageManager_Expecter { + return &MockPackageManager_Expecter{mock: &_m.Mock} +} + +// Extract provides a mock function for the type MockPackageManager +func (_mock *MockPackageManager) Extract(force bool) error { + ret := _mock.Called(force) + + if len(ret) == 0 { + panic("no return value specified for Extract") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(bool) error); ok { + r0 = returnFunc(force) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockPackageManager_Extract_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Extract' +type MockPackageManager_Extract_Call struct { + *mock.Call +} + +// Extract is a helper method to define mock.On call +// - force +func (_e *MockPackageManager_Expecter) Extract(force interface{}) *MockPackageManager_Extract_Call { + return &MockPackageManager_Extract_Call{Call: _e.mock.On("Extract", force)} +} + +func (_c *MockPackageManager_Extract_Call) Run(run func(force bool)) *MockPackageManager_Extract_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(bool)) + }) + return _c +} + +func (_c *MockPackageManager_Extract_Call) Return(err error) *MockPackageManager_Extract_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockPackageManager_Extract_Call) RunAndReturn(run func(force bool) error) *MockPackageManager_Extract_Call { + _c.Call.Return(run) + return _c +} + +// ExtractDependency provides a mock function for the type MockPackageManager +func (_mock *MockPackageManager) ExtractDependency(file string, force bool) error { + ret := _mock.Called(file, force) + + if len(ret) == 0 { + panic("no return value specified for ExtractDependency") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string, bool) error); ok { + r0 = returnFunc(file, force) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockPackageManager_ExtractDependency_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ExtractDependency' +type MockPackageManager_ExtractDependency_Call struct { + *mock.Call +} + +// ExtractDependency is a helper method to define mock.On call +// - file +// - force +func (_e *MockPackageManager_Expecter) ExtractDependency(file interface{}, force interface{}) *MockPackageManager_ExtractDependency_Call { + return &MockPackageManager_ExtractDependency_Call{Call: _e.mock.On("ExtractDependency", file, force)} +} + +func (_c *MockPackageManager_ExtractDependency_Call) Run(run func(file string, force bool)) *MockPackageManager_ExtractDependency_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(bool)) + }) + return _c +} + +func (_c *MockPackageManager_ExtractDependency_Call) Return(err error) *MockPackageManager_ExtractDependency_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockPackageManager_ExtractDependency_Call) RunAndReturn(run func(file string, force bool) error) *MockPackageManager_ExtractDependency_Call { + _c.Call.Return(run) + return _c +} + +// ExtractOciImageIndex provides a mock function for the type MockPackageManager +func (_mock *MockPackageManager) ExtractOciImageIndex(imagefile string) (files.OCIImageIndex, error) { + ret := _mock.Called(imagefile) + + if len(ret) == 0 { + panic("no return value specified for ExtractOciImageIndex") + } + + var r0 files.OCIImageIndex + var r1 error + if returnFunc, ok := ret.Get(0).(func(string) (files.OCIImageIndex, error)); ok { + return returnFunc(imagefile) + } + if returnFunc, ok := ret.Get(0).(func(string) files.OCIImageIndex); ok { + r0 = returnFunc(imagefile) + } else { + r0 = ret.Get(0).(files.OCIImageIndex) + } + if returnFunc, ok := ret.Get(1).(func(string) error); ok { + r1 = returnFunc(imagefile) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockPackageManager_ExtractOciImageIndex_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ExtractOciImageIndex' +type MockPackageManager_ExtractOciImageIndex_Call struct { + *mock.Call +} + +// ExtractOciImageIndex is a helper method to define mock.On call +// - imagefile +func (_e *MockPackageManager_Expecter) ExtractOciImageIndex(imagefile interface{}) *MockPackageManager_ExtractOciImageIndex_Call { + return &MockPackageManager_ExtractOciImageIndex_Call{Call: _e.mock.On("ExtractOciImageIndex", imagefile)} +} + +func (_c *MockPackageManager_ExtractOciImageIndex_Call) Run(run func(imagefile string)) *MockPackageManager_ExtractOciImageIndex_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockPackageManager_ExtractOciImageIndex_Call) Return(oCIImageIndex files.OCIImageIndex, err error) *MockPackageManager_ExtractOciImageIndex_Call { + _c.Call.Return(oCIImageIndex, err) + return _c +} + +func (_c *MockPackageManager_ExtractOciImageIndex_Call) RunAndReturn(run func(imagefile string) (files.OCIImageIndex, error)) *MockPackageManager_ExtractOciImageIndex_Call { + _c.Call.Return(run) + return _c +} + +// FileIO provides a mock function for the type MockPackageManager +func (_mock *MockPackageManager) FileIO() util.FileIO { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for FileIO") + } + + var r0 util.FileIO + if returnFunc, ok := ret.Get(0).(func() util.FileIO); ok { + r0 = returnFunc() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(util.FileIO) + } + } + return r0 +} + +// MockPackageManager_FileIO_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FileIO' +type MockPackageManager_FileIO_Call struct { + *mock.Call +} + +// FileIO is a helper method to define mock.On call +func (_e *MockPackageManager_Expecter) FileIO() *MockPackageManager_FileIO_Call { + return &MockPackageManager_FileIO_Call{Call: _e.mock.On("FileIO")} +} + +func (_c *MockPackageManager_FileIO_Call) Run(run func()) *MockPackageManager_FileIO_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockPackageManager_FileIO_Call) Return(fileIO util.FileIO) *MockPackageManager_FileIO_Call { + _c.Call.Return(fileIO) + return _c +} + +func (_c *MockPackageManager_FileIO_Call) RunAndReturn(run func() util.FileIO) *MockPackageManager_FileIO_Call { + _c.Call.Return(run) + return _c +} + +// GetBaseimageName provides a mock function for the type MockPackageManager +func (_mock *MockPackageManager) GetBaseimageName(baseimage string) (string, error) { + ret := _mock.Called(baseimage) + + if len(ret) == 0 { + panic("no return value specified for GetBaseimageName") + } + + var r0 string + var r1 error + if returnFunc, ok := ret.Get(0).(func(string) (string, error)); ok { + return returnFunc(baseimage) + } + if returnFunc, ok := ret.Get(0).(func(string) string); ok { + r0 = returnFunc(baseimage) + } else { + r0 = ret.Get(0).(string) + } + if returnFunc, ok := ret.Get(1).(func(string) error); ok { + r1 = returnFunc(baseimage) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockPackageManager_GetBaseimageName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetBaseimageName' +type MockPackageManager_GetBaseimageName_Call struct { + *mock.Call +} + +// GetBaseimageName is a helper method to define mock.On call +// - baseimage +func (_e *MockPackageManager_Expecter) GetBaseimageName(baseimage interface{}) *MockPackageManager_GetBaseimageName_Call { + return &MockPackageManager_GetBaseimageName_Call{Call: _e.mock.On("GetBaseimageName", baseimage)} +} + +func (_c *MockPackageManager_GetBaseimageName_Call) Run(run func(baseimage string)) *MockPackageManager_GetBaseimageName_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockPackageManager_GetBaseimageName_Call) Return(s string, err error) *MockPackageManager_GetBaseimageName_Call { + _c.Call.Return(s, err) + return _c +} + +func (_c *MockPackageManager_GetBaseimageName_Call) RunAndReturn(run func(baseimage string) (string, error)) *MockPackageManager_GetBaseimageName_Call { + _c.Call.Return(run) + return _c +} + +// GetBaseimagePath provides a mock function for the type MockPackageManager +func (_mock *MockPackageManager) GetBaseimagePath(baseimage string, force bool) (string, error) { + ret := _mock.Called(baseimage, force) + + if len(ret) == 0 { + panic("no return value specified for GetBaseimagePath") + } + + var r0 string + var r1 error + if returnFunc, ok := ret.Get(0).(func(string, bool) (string, error)); ok { + return returnFunc(baseimage, force) + } + if returnFunc, ok := ret.Get(0).(func(string, bool) string); ok { + r0 = returnFunc(baseimage, force) + } else { + r0 = ret.Get(0).(string) + } + if returnFunc, ok := ret.Get(1).(func(string, bool) error); ok { + r1 = returnFunc(baseimage, force) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockPackageManager_GetBaseimagePath_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetBaseimagePath' +type MockPackageManager_GetBaseimagePath_Call struct { + *mock.Call +} + +// GetBaseimagePath is a helper method to define mock.On call +// - baseimage +// - force +func (_e *MockPackageManager_Expecter) GetBaseimagePath(baseimage interface{}, force interface{}) *MockPackageManager_GetBaseimagePath_Call { + return &MockPackageManager_GetBaseimagePath_Call{Call: _e.mock.On("GetBaseimagePath", baseimage, force)} +} + +func (_c *MockPackageManager_GetBaseimagePath_Call) Run(run func(baseimage string, force bool)) *MockPackageManager_GetBaseimagePath_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(bool)) + }) + return _c +} + +func (_c *MockPackageManager_GetBaseimagePath_Call) Return(s string, err error) *MockPackageManager_GetBaseimagePath_Call { + _c.Call.Return(s, err) + return _c +} + +func (_c *MockPackageManager_GetBaseimagePath_Call) RunAndReturn(run func(baseimage string, force bool) (string, error)) *MockPackageManager_GetBaseimagePath_Call { + _c.Call.Return(run) + return _c +} + +// GetCodesphereVersion provides a mock function for the type MockPackageManager +func (_mock *MockPackageManager) GetCodesphereVersion() (string, error) { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for GetCodesphereVersion") + } + + var r0 string + var r1 error + if returnFunc, ok := ret.Get(0).(func() (string, error)); ok { + return returnFunc() + } + if returnFunc, ok := ret.Get(0).(func() string); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(string) + } + if returnFunc, ok := ret.Get(1).(func() error); ok { + r1 = returnFunc() + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockPackageManager_GetCodesphereVersion_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetCodesphereVersion' +type MockPackageManager_GetCodesphereVersion_Call struct { + *mock.Call +} + +// GetCodesphereVersion is a helper method to define mock.On call +func (_e *MockPackageManager_Expecter) GetCodesphereVersion() *MockPackageManager_GetCodesphereVersion_Call { + return &MockPackageManager_GetCodesphereVersion_Call{Call: _e.mock.On("GetCodesphereVersion")} +} + +func (_c *MockPackageManager_GetCodesphereVersion_Call) Run(run func()) *MockPackageManager_GetCodesphereVersion_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockPackageManager_GetCodesphereVersion_Call) Return(s string, err error) *MockPackageManager_GetCodesphereVersion_Call { + _c.Call.Return(s, err) + return _c +} + +func (_c *MockPackageManager_GetCodesphereVersion_Call) RunAndReturn(run func() (string, error)) *MockPackageManager_GetCodesphereVersion_Call { + _c.Call.Return(run) + return _c +} + +// GetDependencyPath provides a mock function for the type MockPackageManager +func (_mock *MockPackageManager) GetDependencyPath(filename string) string { + ret := _mock.Called(filename) + + if len(ret) == 0 { + panic("no return value specified for GetDependencyPath") + } + + var r0 string + if returnFunc, ok := ret.Get(0).(func(string) string); ok { + r0 = returnFunc(filename) + } else { + r0 = ret.Get(0).(string) + } + return r0 +} + +// MockPackageManager_GetDependencyPath_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetDependencyPath' +type MockPackageManager_GetDependencyPath_Call struct { + *mock.Call +} + +// GetDependencyPath is a helper method to define mock.On call +// - filename +func (_e *MockPackageManager_Expecter) GetDependencyPath(filename interface{}) *MockPackageManager_GetDependencyPath_Call { + return &MockPackageManager_GetDependencyPath_Call{Call: _e.mock.On("GetDependencyPath", filename)} +} + +func (_c *MockPackageManager_GetDependencyPath_Call) Run(run func(filename string)) *MockPackageManager_GetDependencyPath_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockPackageManager_GetDependencyPath_Call) Return(s string) *MockPackageManager_GetDependencyPath_Call { + _c.Call.Return(s) + return _c +} + +func (_c *MockPackageManager_GetDependencyPath_Call) RunAndReturn(run func(filename string) string) *MockPackageManager_GetDependencyPath_Call { + _c.Call.Return(run) + return _c +} + +// GetWorkDir provides a mock function for the type MockPackageManager +func (_mock *MockPackageManager) GetWorkDir() string { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for GetWorkDir") + } + + var r0 string + if returnFunc, ok := ret.Get(0).(func() string); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(string) + } + return r0 +} + +// MockPackageManager_GetWorkDir_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetWorkDir' +type MockPackageManager_GetWorkDir_Call struct { + *mock.Call +} + +// GetWorkDir is a helper method to define mock.On call +func (_e *MockPackageManager_Expecter) GetWorkDir() *MockPackageManager_GetWorkDir_Call { + return &MockPackageManager_GetWorkDir_Call{Call: _e.mock.On("GetWorkDir")} +} + +func (_c *MockPackageManager_GetWorkDir_Call) Run(run func()) *MockPackageManager_GetWorkDir_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockPackageManager_GetWorkDir_Call) Return(s string) *MockPackageManager_GetWorkDir_Call { + _c.Call.Return(s) + return _c +} + +func (_c *MockPackageManager_GetWorkDir_Call) RunAndReturn(run func() string) *MockPackageManager_GetWorkDir_Call { + _c.Call.Return(run) + return _c +} diff --git a/internal/installer/package.go b/internal/installer/package.go index 553e3dac..bd671214 100644 --- a/internal/installer/package.go +++ b/internal/installer/package.go @@ -8,27 +8,70 @@ import ( "log" "os" "path" + "path/filepath" "strings" + "github.com/codesphere-cloud/oms/internal/installer/files" "github.com/codesphere-cloud/oms/internal/util" ) const depsDir = "deps" +type PackageManager interface { + FileIO() util.FileIO + GetWorkDir() string + GetDependencyPath(filename string) string + Extract(force bool) error + ExtractDependency(file string, force bool) error + ExtractOciImageIndex(imagefile string) (files.OCIImageIndex, error) + GetBaseimageName(baseimage string) (string, error) + GetBaseimagePath(baseimage string, force bool) (string, error) + GetCodesphereVersion() (string, error) +} + type Package struct { OmsWorkdir string Filename string - FileIO util.FileIO + fileIO util.FileIO } func NewPackage(omsWorkdir, filename string) *Package { return &Package{ Filename: filename, OmsWorkdir: omsWorkdir, - FileIO: &util.FilesystemWriter{}, + fileIO: &util.FilesystemWriter{}, } } +// FileIO returns the FileIO interface used by the package. +func (p *Package) FileIO() util.FileIO { + return p.fileIO +} + +// GetWorkDir returns the working directory path for the package +// by joining the OmsWorkdir and the filename (without the .tar.gz extension). +func (p *Package) GetWorkDir() string { + return path.Join(p.OmsWorkdir, strings.ReplaceAll(p.Filename, ".tar.gz", "")) +} + +// GetDependencyPath returns the full path to a dependency file within the package's deps directory. +func (p *Package) GetDependencyPath(filename string) string { + workDir := p.GetWorkDir() + return path.Join(workDir, depsDir, filename) +} + +// alreadyExtracted checks if the package has already been extracted to the given directory. +func (p *Package) alreadyExtracted(dir string) (bool, error) { + if !p.fileIO.Exists(dir) { + return false, nil + } + isDir, err := p.fileIO.IsDirectory(dir) + if err != nil { + return false, fmt.Errorf("failed to determine if %s is a folder: %w", dir, err) + } + return isDir, nil +} + // Extract extracts the package tar.gz file into its working directory. // If force is true, it will overwrite existing files. func (p *Package) Extract(force bool) error { @@ -43,11 +86,11 @@ func (p *Package) Extract(force bool) error { return fmt.Errorf("failed to figure out if package %s is already extracted in %s: %w", p.Filename, workDir, err) } if alreadyExtracted && !force { - log.Println("skipping extraction, package already unpacked. Use force option to overwrite.") + log.Println("Skipping extraction, package already unpacked. Use force option to overwrite.") return nil } - err = util.ExtractTarGz(p.FileIO, p.Filename, workDir) + err = util.ExtractTarGz(p.fileIO, p.Filename, workDir) if err != nil { return fmt.Errorf("failed to extract package %s to %s: %w", p.Filename, workDir, err) } @@ -63,12 +106,12 @@ func (p *Package) ExtractDependency(file string, force bool) error { } workDir := p.GetWorkDir() - if p.FileIO.Exists(p.GetDependencyPath(file)) && !force { - log.Println("skipping extraction, dependency already unpacked. Use force option to overwrite.") + if p.fileIO.Exists(p.GetDependencyPath(file)) && !force { + log.Println("Skipping extraction, dependency already unpacked. Use force option to overwrite.") return nil } - err = util.ExtractTarGzSingleFile(p.FileIO, path.Join(workDir, "deps.tar.gz"), file, path.Join(workDir, depsDir)) + err = util.ExtractTarGzSingleFile(p.fileIO, path.Join(workDir, "deps.tar.gz"), file, path.Join(workDir, depsDir)) if err != nil { return fmt.Errorf("failed to extract dependency %s from deps archive to %s: %w", file, workDir, err) } @@ -76,25 +119,96 @@ func (p *Package) ExtractDependency(file string, force bool) error { return err } -func (p *Package) alreadyExtracted(dir string) (bool, error) { - if !p.FileIO.Exists(dir) { - return false, nil +// ExtractOciImageIndex extracts and parses the OCI image index from the given image file path. +func (p *Package) ExtractOciImageIndex(imagefile string) (files.OCIImageIndex, error) { + var ociImageIndex files.OCIImageIndex + err := util.ExtractTarSingleFile(p.fileIO, imagefile, "index.json", filepath.Dir(imagefile)) + if err != nil { + return ociImageIndex, fmt.Errorf("failed to extract index.json: %w", err) } - isDir, err := p.FileIO.IsDirectory(dir) + + err = ociImageIndex.ParseOCIImageConfig(imagefile) if err != nil { - return false, fmt.Errorf("failed to determine if %s is a folder: %w", dir, err) + return ociImageIndex, fmt.Errorf("failed to parse OCI image config: %w", err) } - return isDir, nil + + return ociImageIndex, nil } -// GetWorkDir returns the working directory path for the package -// by joining the OmsWorkdir and the filename (without the .tar.gz extension). -func (p *Package) GetWorkDir() string { - return path.Join(p.OmsWorkdir, strings.ReplaceAll(p.Filename, ".tar.gz", "")) +func (p *Package) GetBaseimageName(baseimage string) (string, error) { + if baseimage == "" { + return "", fmt.Errorf("baseimage not specified") + } + + bomJson := files.BomConfig{} + err := bomJson.ParseBomConfig(p.GetDependencyPath("bom.json")) + if err != nil { + return "", fmt.Errorf("failed to load bom.json: %w", err) + } + + containerImages, err := bomJson.GetCodesphereContainerImages() + if err != nil { + return "", fmt.Errorf("failed to get codesphere container images from bom.json: %w", err) + } + + imageName, exists := containerImages[baseimage] + if !exists { + return "", fmt.Errorf("baseimage %s not found in bom.json", baseimage) + } + + return imageName, nil } -// GetDependencyPath returns the full path to a dependency file within the package's deps directory. -func (p *Package) GetDependencyPath(filename string) string { - workDir := p.GetWorkDir() - return path.Join(workDir, depsDir, filename) +const baseimagePath = "./codesphere/images" + +func (p *Package) GetBaseimagePath(baseimage string, force bool) (string, error) { + if baseimage == "" { + return "", fmt.Errorf("baseimage not specified") + } + + if !strings.HasSuffix(baseimage, ".tar") { + baseimage = baseimage + ".tar" + } + + baseImageTarPath := path.Join(baseimagePath, baseimage) + err := p.ExtractDependency(baseImageTarPath, force) + if err != nil { + return "", fmt.Errorf("failed to extract package to workdir: %w", err) + } + + baseimagePath := p.GetDependencyPath(baseImageTarPath) + + return baseimagePath, nil +} + +func (p *Package) GetCodesphereVersion() (string, error) { + bomJson := files.BomConfig{} + err := bomJson.ParseBomConfig(p.GetDependencyPath("bom.json")) + if err != nil { + return "", fmt.Errorf("failed to load bom.json: %w", err) + } + + containerImages, err := bomJson.GetCodesphereContainerImages() + if err != nil { + return "", fmt.Errorf("failed to get codesphere container images from bom.json: %w", err) + } + + containerImage := "" + for _, image := range containerImages { + if strings.Contains(image, "codesphere-v") { + containerImage = image + break + } + } + + if containerImage == "" { + return "", fmt.Errorf("no container images found in bom.json") + } + + parts := strings.Split(containerImage, ":") + if len(parts) < 2 { + return "", fmt.Errorf("invalid image name format: %s", containerImage) + } + + return parts[len(parts)-1], nil } diff --git a/internal/installer/package_test.go b/internal/installer/package_test.go new file mode 100644 index 00000000..2d65b8fe --- /dev/null +++ b/internal/installer/package_test.go @@ -0,0 +1,724 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer_test + +import ( + "archive/tar" + "compress/gzip" + "os" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/codesphere-cloud/oms/internal/installer" + "github.com/codesphere-cloud/oms/internal/util" +) + +var _ = Describe("Package", func() { + var ( + pkg *installer.Package + tempDir string + omsWorkdir string + filename string + ) + + BeforeEach(func() { + tempDir = GinkgoT().TempDir() + omsWorkdir = filepath.Join(tempDir, "oms-workdir") + filename = "test-package.tar.gz" + pkg = installer.NewPackage(omsWorkdir, filename) + }) + + Describe("NewPackage", func() { + It("creates a new Package with correct parameters", func() { + newPkg := installer.NewPackage("/test/workdir", "package.tar.gz") + Expect(newPkg).ToNot(BeNil()) + Expect(newPkg.OmsWorkdir).To(Equal("/test/workdir")) + Expect(newPkg.Filename).To(Equal("package.tar.gz")) + Expect(newPkg.FileIO()).ToNot(BeNil()) + Expect(newPkg.FileIO()).To(BeAssignableToTypeOf(&util.FilesystemWriter{})) + }) + }) + + Describe("FileIO", func() { + It("returns the FileIO interface", func() { + fileIO := pkg.FileIO() + Expect(fileIO).ToNot(BeNil()) + Expect(fileIO).To(BeAssignableToTypeOf(&util.FilesystemWriter{})) + }) + }) + + Describe("GetWorkDir", func() { + It("returns correct working directory path", func() { + expected := filepath.Join(omsWorkdir, "test-package") + Expect(pkg.GetWorkDir()).To(Equal(expected)) + }) + + It("removes .tar.gz extension from filename", func() { + pkg.Filename = "my-package.tar.gz" + 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() { + It("returns correct dependency path", func() { + filename := "dependency.txt" + workDir := pkg.GetWorkDir() + expected := filepath.Join(workDir, "deps", filename) + Expect(pkg.GetDependencyPath(filename)).To(Equal(expected)) + }) + + It("handles dependency files with paths", func() { + filename := "subfolder/dependency.txt" + workDir := pkg.GetWorkDir() + 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() { + Context("with real filesystem operations", func() { + BeforeEach(func() { + // Create the package tar.gz file + packagePath := filepath.Join(tempDir, filename) + err := createTestTarGzPackage(packagePath) + Expect(err).ToNot(HaveOccurred()) + pkg.Filename = packagePath + }) + + Context("when package doesn't exist", func() { + It("returns an error", func() { + pkg.Filename = "/nonexistent/package.tar.gz" + err := pkg.Extract(false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to extract package")) + }) + }) + + Context("when package exists and workdir doesn't exist", func() { + It("successfully extracts the package", func() { + err := pkg.Extract(false) + Expect(err).ToNot(HaveOccurred()) + + // Verify that the workdir was created + workDir := pkg.GetWorkDir() + Expect(workDir).To(BeADirectory()) + + // Verify that the content was extracted + testFile := filepath.Join(workDir, "test-file.txt") + Expect(testFile).To(BeAnExistingFile()) + }) + }) + + Context("when package is already extracted", func() { + BeforeEach(func() { + // First extraction + err := pkg.Extract(false) + Expect(err).ToNot(HaveOccurred()) + }) + + It("skips extraction without force", func() { + err := pkg.Extract(false) + Expect(err).ToNot(HaveOccurred()) + }) + + It("re-extracts with force", func() { + err := pkg.Extract(true) + Expect(err).ToNot(HaveOccurred()) + + // Verify content still exists + workDir := pkg.GetWorkDir() + testFile := filepath.Join(workDir, "test-file.txt") + Expect(testFile).To(BeAnExistingFile()) + }) + }) + + Context("when workdir creation fails", func() { + It("returns an error for invalid workdir", func() { + // Use a path that can't be created (file exists as directory) + invalidWorkdir := filepath.Join(tempDir, "invalid-workdir") + err := os.WriteFile(invalidWorkdir, []byte("content"), 0644) + Expect(err).ToNot(HaveOccurred()) + + pkg.OmsWorkdir = invalidWorkdir + err = pkg.Extract(false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to ensure workdir exists")) + }) + }) + }) + }) + + Describe("ExtractDependency", func() { + Context("with real filesystem operations", func() { + var packagePath string + + BeforeEach(func() { + // Create the package tar.gz file with deps.tar.gz inside + packagePath = filepath.Join(tempDir, filename) + err := createTestTarGzPackageWithDeps(packagePath) + Expect(err).ToNot(HaveOccurred()) + pkg.Filename = packagePath + }) + + Context("when dependency file exists in deps.tar.gz", func() { + It("successfully extracts the dependency", func() { + err := pkg.ExtractDependency("test-dep.txt", false) + Expect(err).ToNot(HaveOccurred()) + + // Verify that the dependency was extracted + depPath := pkg.GetDependencyPath("test-dep.txt") + Expect(depPath).To(BeAnExistingFile()) + }) + }) + + Context("when dependency is already extracted", func() { + BeforeEach(func() { + // First extraction + err := pkg.ExtractDependency("test-dep.txt", false) + Expect(err).ToNot(HaveOccurred()) + }) + + It("skips extraction without force", func() { + err := pkg.ExtractDependency("test-dep.txt", false) + Expect(err).ToNot(HaveOccurred()) + }) + + It("re-extracts with force", func() { + err := pkg.ExtractDependency("test-dep.txt", true) + Expect(err).ToNot(HaveOccurred()) + + // Verify dependency still exists + depPath := pkg.GetDependencyPath("test-dep.txt") + Expect(depPath).To(BeAnExistingFile()) + }) + }) + + Context("when package extraction fails", func() { + It("returns an error", func() { + pkg.Filename = "/nonexistent/package.tar.gz" + err := pkg.ExtractDependency("test-dep.txt", false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to extract package")) + }) + }) + + Context("when dependency doesn't exist in deps.tar.gz", func() { + It("returns an error", func() { + err := pkg.ExtractDependency("nonexistent-dep.txt", false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to extract dependency")) + }) + }) + }) + }) + + 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 := createTestTarGzPackage(packagePath) + 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 := createTestTarGzPackageWithDeps(packagePath) + 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 := createComplexTestPackage(packagePath) + 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)) + } + }) + }) + }) +}) + +// Helper functions for creating test tar.gz files + +// createTestTarGzPackage creates a simple tar.gz package for testing +func createTestTarGzPackage(filename string) error { + file, err := os.Create(filename) + if err != nil { + return err + } + defer util.CloseFileIgnoreError(file) + + gzw := gzip.NewWriter(file) + defer func() { _ = gzw.Close() }() + + tw := tar.NewWriter(gzw) + defer func() { _ = tw.Close() }() + + // Add a test file + content := "test content" + header := &tar.Header{ + Name: "test-file.txt", + Mode: 0644, + Size: int64(len(content)), + } + if err := tw.WriteHeader(header); err != nil { + return err + } + if _, err := tw.Write([]byte(content)); err != nil { + return err + } + + return nil +} + +// createTestTarGzPackageWithDeps creates a tar.gz package containing a deps.tar.gz file +func createTestTarGzPackageWithDeps(filename string) error { + file, err := os.Create(filename) + if err != nil { + return err + } + defer util.CloseFileIgnoreError(file) + + gzw := gzip.NewWriter(file) + defer func() { _ = gzw.Close() }() + + tw := tar.NewWriter(gzw) + defer func() { _ = tw.Close() }() + + // Add main content + mainContent := "main package content" + header := &tar.Header{ + Name: "main-file.txt", + Mode: 0644, + Size: int64(len(mainContent)), + } + if err := tw.WriteHeader(header); err != nil { + return err + } + if _, err := tw.Write([]byte(mainContent)); err != nil { + return err + } + + // Create deps.tar.gz content in memory + depsContent, err := createDepsArchive() + if err != nil { + return err + } + + // Add deps.tar.gz file + depsHeader := &tar.Header{ + Name: "deps.tar.gz", + Mode: 0644, + Size: int64(len(depsContent)), + } + if err := tw.WriteHeader(depsHeader); err != nil { + return err + } + if _, err := tw.Write(depsContent); err != nil { + return err + } + + return nil +} + +// createComplexTestPackage creates a complex package for integration testing +func createComplexTestPackage(filename string) error { + file, err := os.Create(filename) + if err != nil { + return err + } + defer util.CloseFileIgnoreError(file) + + gzw := gzip.NewWriter(file) + defer func() { _ = gzw.Close() }() + + tw := tar.NewWriter(gzw) + defer func() { _ = tw.Close() }() + + // Add main content + mainContent := "complex main package content" + header := &tar.Header{ + Name: "main-content.txt", + Mode: 0644, + Size: int64(len(mainContent)), + } + if err := tw.WriteHeader(header); err != nil { + return err + } + if _, err := tw.Write([]byte(mainContent)); err != nil { + return err + } + + // Create complex deps.tar.gz content + depsContent, err := createComplexDepsArchive() + if err != nil { + return err + } + + // Add deps.tar.gz file + depsHeader := &tar.Header{ + Name: "deps.tar.gz", + Mode: 0644, + Size: int64(len(depsContent)), + } + if err := tw.WriteHeader(depsHeader); err != nil { + return err + } + if _, err := tw.Write(depsContent); err != nil { + return err + } + + return nil +} + +// createDepsArchive creates a deps.tar.gz archive content in memory +func createDepsArchive() ([]byte, error) { + var buf []byte + gzw := gzip.NewWriter(&bytesBuffer{data: &buf}) + tw := tar.NewWriter(gzw) + + // Add test dependency + depContent := "dependency content" + header := &tar.Header{ + Name: "test-dep.txt", + Mode: 0644, + Size: int64(len(depContent)), + } + if err := tw.WriteHeader(header); err != nil { + return nil, err + } + if _, err := tw.Write([]byte(depContent)); err != nil { + return nil, err + } + + if err := tw.Close(); err != nil { + return nil, err + } + if err := gzw.Close(); err != nil { + return nil, err + } + + return buf, nil +} + +// createComplexDepsArchive creates a complex deps.tar.gz archive with multiple files +func createComplexDepsArchive() ([]byte, error) { + var buf []byte + gzw := gzip.NewWriter(&bytesBuffer{data: &buf}) + tw := tar.NewWriter(gzw) + + // Add multiple dependencies + deps := map[string]string{ + "dep1.txt": "dependency 1 content", + "dep2.txt": "dependency 2 content", + "subdep/dep3.txt": "sub dependency 3 content", + } + + for name, content := range deps { + header := &tar.Header{ + Name: name, + Mode: 0644, + Size: int64(len(content)), + } + if err := tw.WriteHeader(header); err != nil { + return nil, err + } + if _, err := tw.Write([]byte(content)); err != nil { + return nil, err + } + } + + if err := tw.Close(); err != nil { + return nil, err + } + if err := gzw.Close(); err != nil { + return nil, err + } + + return buf, nil +} + +// bytesBuffer is a simple buffer that implements io.Writer for creating in-memory archives +type bytesBuffer struct { + data *[]byte +} + +func (b *bytesBuffer) Write(p []byte) (n int, err error) { + *b.data = append(*b.data, p...) + return len(p), nil +} + +// Tests for ExtractOciImageIndex (moved from config_test.go) +var _ = Describe("Package ExtractOciImageIndex", func() { + var ( + pkg *installer.Package + tempDir string + ) + + BeforeEach(func() { + tempDir = GinkgoT().TempDir() + pkg = installer.NewPackage(tempDir, "test-package.tar.gz") + }) + + Describe("ExtractOciImageIndex", func() { + Context("with real filesystem operations", func() { + var imageFile string + + BeforeEach(func() { + imageFile = filepath.Join(tempDir, "test-image.tar") + }) + + Context("when image file does not exist", func() { + It("returns an error", func() { + _, err := pkg.ExtractOciImageIndex(imageFile) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to extract index.json")) + }) + }) + + Context("when image file is empty", func() { + It("returns an error", func() { + // Create empty tar file + err := os.WriteFile(imageFile, []byte(""), 0644) + Expect(err).ToNot(HaveOccurred()) + + _, err = pkg.ExtractOciImageIndex(imageFile) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to extract index.json")) + }) + }) + + Context("when image file is a directory", func() { + It("returns an error", func() { + // Create directory instead of file + err := os.Mkdir(imageFile, 0755) + Expect(err).ToNot(HaveOccurred()) + + _, err = pkg.ExtractOciImageIndex(imageFile) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to extract index.json")) + }) + }) + + Context("when index.json file doesn't exist after extraction", func() { + It("returns an error", func() { + // Create a minimal tar file without index.json + err := createTar(imageFile, "not_index.json", "fake content") + Expect(err).ToNot(HaveOccurred()) + + _, err = pkg.ExtractOciImageIndex(imageFile) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to extract index.json")) + }) + }) + + Context("when tar contains valid index.json", func() { + It("successfully extracts and parses OCI image index", func() { + // Create a tar file with a valid index.json + validIndex := `{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.index.v1+json", + "manifests": [ + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "size": 1234, + "digest": "sha256:abc123def456" + } + ] + }` + err := createTar(imageFile, "index.json", validIndex) + Expect(err).ToNot(HaveOccurred()) + + ociImageIndex, err := pkg.ExtractOciImageIndex(imageFile) + Expect(err).ToNot(HaveOccurred()) + Expect(ociImageIndex.SchemaVersion).To(Equal(2)) + Expect(ociImageIndex.MediaType).To(Equal("application/vnd.oci.image.index.v1+json")) + Expect(ociImageIndex.Manifests).To(HaveLen(1)) + Expect(ociImageIndex.Manifests[0].Digest).To(Equal("sha256:abc123def456")) + Expect(ociImageIndex.Manifests[0].Size).To(Equal(int64(1234))) + }) + }) + + Context("when index.json has invalid JSON", func() { + It("returns an error", func() { + // Create a tar file with invalid JSON in index.json + invalidIndex := `{ + "schemaVersion": 2, + "manifests": [ + { + "size": "invalid_json_here", + ` + err := createTar(imageFile, "index.json", invalidIndex) + Expect(err).ToNot(HaveOccurred()) + + _, err = pkg.ExtractOciImageIndex(imageFile) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to parse OCI image config")) + }) + }) + }) + + 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")) + }) + }) + }) +}) + +// createTar creates a tar file containing a file with the given content +func createTar(tarName string, fileName string, fileContent string) error { + file, err := os.Create(tarName) + if err != nil { + return err + } + defer util.CloseFileIgnoreError(file) + + tw := tar.NewWriter(file) + defer func() { _ = tw.Close() }() + + // Add the specified file + header := &tar.Header{ + Name: fileName, + Mode: 0644, + Size: int64(len(fileContent)), + } + if err := tw.WriteHeader(header); err != nil { + return err + } + if _, err := tw.Write([]byte(fileContent)); err != nil { + return err + } + + return nil +} diff --git a/internal/system/container.go b/internal/system/container.go deleted file mode 100644 index c64bc95b..00000000 --- a/internal/system/container.go +++ /dev/null @@ -1,107 +0,0 @@ -// Copyright (c) Codesphere Inc. -// SPDX-License-Identifier: Apache-2.0 - -package system - -import ( - "encoding/json" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - - "github.com/codesphere-cloud/oms/internal/util" -) - -type DockerEngine struct { -} - -type ContainerEngine interface { - LoadLocalContainerImage(filename string) error - BuildImage(dockerfile string) error -} - -func NewDockerEngine() *DockerEngine { - return &DockerEngine{} -} - -func (d *DockerEngine) LoadLocalContainerImage(imagefile string) error { - err := d.RunCommand([]string{"load", "--input", imagefile}) - - if err != nil { - return fmt.Errorf("failed to load image %s: %w", imagefile, err) - } - - return nil -} - -// OCIImageIndex represents the top-level structure of an OCI Image Index (manifest list). -type OCIImageIndex struct { - SchemaVersion int `json:"schemaVersion"` - MediaType string `json:"mediaType"` - Manifests []ManifestEntry `json:"manifests"` -} - -// ManifestEntry represents a single manifest entry within the index. -type ManifestEntry struct { - MediaType string `json:"mediaType"` - Digest string `json:"digest"` - Size int64 `json:"size"` - Annotations map[string]string `json:"annotations,omitempty"` // Use omitempty just in case, though usually present -} - -func (d *DockerEngine) GetImageNames(fileIo util.FileIO, imagefile string) ([]string, error) { - names := []string{} - err := util.ExtractTarSingleFile(fileIo, imagefile, "index.json", filepath.Dir(imagefile)) - - if err != nil { - return names, fmt.Errorf("failed to extract index.json: %w", err) - } - - indexfile := filepath.Join(filepath.Dir(imagefile), "index.json") - file, err := os.Open(indexfile) - if err != nil { - return names, fmt.Errorf("failed to open file %s: %w", indexfile, err) - } - defer util.CloseFileIgnoreError(file) - - var index OCIImageIndex - decoder := json.NewDecoder(file) - err = decoder.Decode(&index) - - if err != nil { - return names, fmt.Errorf("failed to decode file %s: %w", indexfile, err) - } - - for _, manifest := range index.Manifests { - name := manifest.Annotations["io.containerd.image.name"] - if name == "" { - continue - } - names = append(names, name) - } - return names, nil -} - -func (d *DockerEngine) RunCommand(dockerCmd []string) error { - err := RunCommandAndStreamOutput("docker", dockerCmd...) - if err != nil { - return fmt.Errorf("failed to run docker command `docker \"%s\"`: %w", strings.Join(dockerCmd, "\" \""), err) - } - - return nil -} - -func RunCommandAndStreamOutput(name string, args ...string) error { - cmd := exec.Command(name, args...) - - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - err := cmd.Run() - if err != nil { - return fmt.Errorf("failed to run command: %w", err) - } - return nil -} diff --git a/internal/system/image.go b/internal/system/image.go new file mode 100644 index 00000000..96224617 --- /dev/null +++ b/internal/system/image.go @@ -0,0 +1,84 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package system + +import ( + "context" + "fmt" + "os" + "os/exec" +) + +type Image struct { + ctx context.Context +} + +type ImageManager interface { + LoadImage(imageTarPath string) error + BuildImage(dockerfile string, tag string, buildContext string) error + PushImage(tag string) error +} + +func NewImage(ctx context.Context) *Image { + return &Image{ + ctx: ctx, + } +} + +func isCommandAvailable(name string) bool { + cmd := exec.Command(name, "-v") + if err := cmd.Run(); err != nil { + return false + } + return true +} + +func (i *Image) LoadImage(imageTarPath string) error { + err := i.runCommand("", "load", "-i", imageTarPath) + if err != nil { + return fmt.Errorf("load failed: %w", err) + } + return nil +} + +func (i *Image) BuildImage(dockerfile string, tag string, buildContext string) error { + err := i.runCommand(buildContext, "build", "-f", dockerfile, "-t", tag, ".") + if err != nil { + return fmt.Errorf("build failed: %w", err) + } + return nil +} + +func (i *Image) PushImage(tag string) error { + err := i.runCommand("", "push", tag) + if err != nil { + return fmt.Errorf("push failed: %w", err) + } + return nil +} + +func (i *Image) runCommand(cmdDir string, args ...string) error { + var cmd *exec.Cmd + if isCommandAvailable("docker") { + cmd = exec.CommandContext(i.ctx, "docker", args...) + } else if isCommandAvailable("podman") { + cmd = exec.CommandContext(i.ctx, "podman", args...) + } else { + return fmt.Errorf("neither 'docker' nor 'podman' command is available") + } + + 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 +} diff --git a/internal/system/mocks.go b/internal/system/mocks.go new file mode 100644 index 00000000..cf8e9292 --- /dev/null +++ b/internal/system/mocks.go @@ -0,0 +1,173 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package system + +import ( + mock "github.com/stretchr/testify/mock" +) + +// NewMockImageManager creates a new instance of MockImageManager. 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 NewMockImageManager(t interface { + mock.TestingT + Cleanup(func()) +}) *MockImageManager { + mock := &MockImageManager{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockImageManager is an autogenerated mock type for the ImageManager type +type MockImageManager struct { + mock.Mock +} + +type MockImageManager_Expecter struct { + mock *mock.Mock +} + +func (_m *MockImageManager) EXPECT() *MockImageManager_Expecter { + return &MockImageManager_Expecter{mock: &_m.Mock} +} + +// BuildImage provides a mock function for the type MockImageManager +func (_mock *MockImageManager) BuildImage(dockerfile string, tag string, buildContext string) error { + ret := _mock.Called(dockerfile, tag, buildContext) + + if len(ret) == 0 { + panic("no return value specified for BuildImage") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string, string, string) error); ok { + r0 = returnFunc(dockerfile, tag, buildContext) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockImageManager_BuildImage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BuildImage' +type MockImageManager_BuildImage_Call struct { + *mock.Call +} + +// BuildImage is a helper method to define mock.On call +// - dockerfile +// - tag +// - buildContext +func (_e *MockImageManager_Expecter) BuildImage(dockerfile interface{}, tag interface{}, buildContext interface{}) *MockImageManager_BuildImage_Call { + return &MockImageManager_BuildImage_Call{Call: _e.mock.On("BuildImage", dockerfile, tag, buildContext)} +} + +func (_c *MockImageManager_BuildImage_Call) Run(run func(dockerfile string, tag string, buildContext string)) *MockImageManager_BuildImage_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(string), args[2].(string)) + }) + return _c +} + +func (_c *MockImageManager_BuildImage_Call) Return(err error) *MockImageManager_BuildImage_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockImageManager_BuildImage_Call) RunAndReturn(run func(dockerfile string, tag string, buildContext string) error) *MockImageManager_BuildImage_Call { + _c.Call.Return(run) + return _c +} + +// LoadImage provides a mock function for the type MockImageManager +func (_mock *MockImageManager) LoadImage(imageTarPath string) error { + ret := _mock.Called(imageTarPath) + + if len(ret) == 0 { + panic("no return value specified for LoadImage") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string) error); ok { + r0 = returnFunc(imageTarPath) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockImageManager_LoadImage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LoadImage' +type MockImageManager_LoadImage_Call struct { + *mock.Call +} + +// LoadImage is a helper method to define mock.On call +// - imageTarPath +func (_e *MockImageManager_Expecter) LoadImage(imageTarPath interface{}) *MockImageManager_LoadImage_Call { + return &MockImageManager_LoadImage_Call{Call: _e.mock.On("LoadImage", imageTarPath)} +} + +func (_c *MockImageManager_LoadImage_Call) Run(run func(imageTarPath string)) *MockImageManager_LoadImage_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockImageManager_LoadImage_Call) Return(err error) *MockImageManager_LoadImage_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockImageManager_LoadImage_Call) RunAndReturn(run func(imageTarPath string) error) *MockImageManager_LoadImage_Call { + _c.Call.Return(run) + return _c +} + +// PushImage provides a mock function for the type MockImageManager +func (_mock *MockImageManager) PushImage(tag string) error { + ret := _mock.Called(tag) + + if len(ret) == 0 { + panic("no return value specified for PushImage") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string) error); ok { + r0 = returnFunc(tag) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockImageManager_PushImage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PushImage' +type MockImageManager_PushImage_Call struct { + *mock.Call +} + +// PushImage is a helper method to define mock.On call +// - tag +func (_e *MockImageManager_Expecter) PushImage(tag interface{}) *MockImageManager_PushImage_Call { + return &MockImageManager_PushImage_Call{Call: _e.mock.On("PushImage", tag)} +} + +func (_c *MockImageManager_PushImage_Call) Run(run func(tag string)) *MockImageManager_PushImage_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockImageManager_PushImage_Call) Return(err error) *MockImageManager_PushImage_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockImageManager_PushImage_Call) RunAndReturn(run func(tag string) error) *MockImageManager_PushImage_Call { + _c.Call.Return(run) + return _c +} diff --git a/internal/tmpl/NOTICE b/internal/tmpl/NOTICE index 2aaf1c7f..be205461 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.8 +Version: v6.6.9 License: MIT -License URL: https://github.com/jedib0t/go-pretty/blob/v6.6.8/LICENSE +License URL: https://github.com/jedib0t/go-pretty/blob/v6.6.9/LICENSE ---------- Module: github.com/mattn/go-runewidth diff --git a/internal/util/docker.go b/internal/util/docker.go new file mode 100644 index 00000000..f489f6dd --- /dev/null +++ b/internal/util/docker.go @@ -0,0 +1,49 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package util + +import ( + "fmt" + "io" + "regexp" + "strings" +) + +// DockerfileManager provides functionality to parse and modify Dockerfiles +type DockerfileManager interface { + UpdateFromStatement(dockerfile io.Reader, baseImage string) (string, error) +} + +type Dockerfile struct{} + +// NewDockerfileManager creates a new instance of DockerfileManager +func NewDockerfileManager() DockerfileManager { + return &Dockerfile{} +} + +// UpdateFromStatement updates the FROM statement in a Dockerfile with a new base image +func (dm *Dockerfile) UpdateFromStatement(dockerfile io.Reader, baseImage string) (string, error) { + content, err := io.ReadAll(dockerfile) + if err != nil { + return "", fmt.Errorf("error reading dockerfile: %w", err) + } + + // Regex to match FROM statements that contain workspace-agent + fromRegex := regexp.MustCompile(`(?i)(.*FROM\s+).*workspace-agent[^\s]*(.*)`) + + updated := false + lines := strings.Split(string(content), "\n") + for i, line := range lines { + if fromRegex.MatchString(line) { + lines[i] = fromRegex.ReplaceAllString(line, "${1}"+baseImage+"${2}") + updated = true + } + } + + if !updated { + return "", fmt.Errorf("no FROM statement with workspace-agent found in dockerfile") + } + + return strings.Join(lines, "\n"), nil +} diff --git a/internal/util/docker_test.go b/internal/util/docker_test.go new file mode 100644 index 00000000..19c414dc --- /dev/null +++ b/internal/util/docker_test.go @@ -0,0 +1,95 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package util_test + +import ( + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/codesphere-cloud/oms/internal/util" +) + +var _ = Describe("Docker", func() { + var dockerfileManager util.DockerfileManager + + BeforeEach(func() { + dockerfileManager = util.NewDockerfileManager() + }) + + Describe("UpdateFromStatement", func() { + It("updates a simple FROM statement with workspace-agent", func() { + dockerfile := `FROM workspace-agent-24.04:codesphere-v1.0.0 +RUN apt-get update +WORKDIR /app` + + result, err := dockerfileManager.UpdateFromStatement(strings.NewReader(dockerfile), "workspace-agent-24.04:codesphere-v1.0.1") + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(`FROM workspace-agent-24.04:codesphere-v1.0.1 +RUN apt-get update +WORKDIR /app`)) + }) + + It("updates FROM statement with workspace-agent and platform flag", func() { + dockerfile := `FROM --platform=linux/amd64 workspace-agent-24.04:codesphere-v1.0.0 +RUN apt-get update +WORKDIR /app` + + result, err := dockerfileManager.UpdateFromStatement(strings.NewReader(dockerfile), "workspace-agent-24.04:codesphere-v1.0.1") + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(`FROM workspace-agent-24.04:codesphere-v1.0.1 +RUN apt-get update +WORKDIR /app`)) + }) + + It("handles case-insensitive FROM statement with workspace-agent", func() { + dockerfile := `from workspace-agent-24.04:codesphere-v1.0.0 +RUN apt-get update +WORKDIR /app` + + result, err := dockerfileManager.UpdateFromStatement(strings.NewReader(dockerfile), "workspace-agent-24.04:codesphere-v1.0.1") + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(`from workspace-agent-24.04:codesphere-v1.0.1 +RUN apt-get update +WORKDIR /app`)) + }) + + It("handles FROM with complex workspace-agent image names", func() { + dockerfile := `FROM registry.example.com:5000/workspace-agent-24.04:codesphere-v1.0.0 +RUN apt-get update +WORKDIR /app` + + result, err := dockerfileManager.UpdateFromStatement(strings.NewReader(dockerfile), "new-registry.com/workspace-agent-24.04:codesphere-v1.0.1") + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(`FROM new-registry.com/workspace-agent-24.04:codesphere-v1.0.1 +RUN apt-get update +WORKDIR /app`)) + }) + + It("updates the last FROM statement in multi-stage builds", func() { + dockerfile := `FROM alpine:3.14 +RUN apt-get update +FROM workspace-agent-24.04:20.04 as builder +COPY --from=0 /app /app` + + result, err := dockerfileManager.UpdateFromStatement(strings.NewReader(dockerfile), "workspace-agent-24.04:codesphere-v1.0.1") + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(`FROM alpine:3.14 +RUN apt-get update +FROM workspace-agent-24.04:codesphere-v1.0.1 as builder +COPY --from=0 /app /app`)) + }) + + It("returns error when no FROM statement with workspace-agent found", func() { + dockerfile := `FROM ubuntu:20.04 +RUN apt-get update +WORKDIR /app` + + _, err := dockerfileManager.UpdateFromStatement(strings.NewReader(dockerfile), "workspace-agent-24.04:codesphere-v1.0.1") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("no FROM statement with workspace-agent found in dockerfile")) + }) + }) +}) diff --git a/internal/util/filewriter.go b/internal/util/filewriter.go index b686039d..9cca2f45 100644 --- a/internal/util/filewriter.go +++ b/internal/util/filewriter.go @@ -8,12 +8,13 @@ import ( ) type FileIO interface { - MkdirAll(path string, perm os.FileMode) error - OpenFile(name string, flag int, perm os.FileMode) (*os.File, error) - Open(filename string) (*os.File, error) Create(filename string) (*os.File, error) - IsDirectory(filename string) (bool, error) + Open(filename string) (*os.File, error) Exists(filename string) bool + IsDirectory(filename string) (bool, error) + MkdirAll(path string, perm os.FileMode) error + OpenFile(name string, flag int, perm os.FileMode) (*os.File, error) + WriteFile(filename string, data []byte, perm os.FileMode) error ReadDir(dirname string) ([]os.DirEntry, error) } @@ -56,6 +57,10 @@ func (fs *FilesystemWriter) OpenFile(name string, flag int, perm os.FileMode) (* return os.OpenFile(name, flag, perm) } +func (fs *FilesystemWriter) WriteFile(filename string, data []byte, perm os.FileMode) error { + return os.WriteFile(filename, data, perm) +} + func (fs *FilesystemWriter) ReadDir(dirname string) ([]os.DirEntry, error) { return os.ReadDir(dirname) } diff --git a/internal/util/mocks.go b/internal/util/mocks.go index ae55b5ba..69eedc26 100644 --- a/internal/util/mocks.go +++ b/internal/util/mocks.go @@ -7,9 +7,92 @@ package util import ( "github.com/jedib0t/go-pretty/v6/table" mock "github.com/stretchr/testify/mock" + "io" "os" ) +// NewMockDockerfileManager creates a new instance of MockDockerfileManager. 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 NewMockDockerfileManager(t interface { + mock.TestingT + Cleanup(func()) +}) *MockDockerfileManager { + mock := &MockDockerfileManager{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockDockerfileManager is an autogenerated mock type for the DockerfileManager type +type MockDockerfileManager struct { + mock.Mock +} + +type MockDockerfileManager_Expecter struct { + mock *mock.Mock +} + +func (_m *MockDockerfileManager) EXPECT() *MockDockerfileManager_Expecter { + return &MockDockerfileManager_Expecter{mock: &_m.Mock} +} + +// UpdateFromStatement provides a mock function for the type MockDockerfileManager +func (_mock *MockDockerfileManager) UpdateFromStatement(dockerfile io.Reader, baseImage string) (string, error) { + ret := _mock.Called(dockerfile, baseImage) + + if len(ret) == 0 { + panic("no return value specified for UpdateFromStatement") + } + + var r0 string + var r1 error + if returnFunc, ok := ret.Get(0).(func(io.Reader, string) (string, error)); ok { + return returnFunc(dockerfile, baseImage) + } + if returnFunc, ok := ret.Get(0).(func(io.Reader, string) string); ok { + r0 = returnFunc(dockerfile, baseImage) + } else { + r0 = ret.Get(0).(string) + } + if returnFunc, ok := ret.Get(1).(func(io.Reader, string) error); ok { + r1 = returnFunc(dockerfile, baseImage) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockDockerfileManager_UpdateFromStatement_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateFromStatement' +type MockDockerfileManager_UpdateFromStatement_Call struct { + *mock.Call +} + +// UpdateFromStatement is a helper method to define mock.On call +// - dockerfile +// - baseImage +func (_e *MockDockerfileManager_Expecter) UpdateFromStatement(dockerfile interface{}, baseImage interface{}) *MockDockerfileManager_UpdateFromStatement_Call { + return &MockDockerfileManager_UpdateFromStatement_Call{Call: _e.mock.On("UpdateFromStatement", dockerfile, baseImage)} +} + +func (_c *MockDockerfileManager_UpdateFromStatement_Call) Run(run func(dockerfile io.Reader, baseImage string)) *MockDockerfileManager_UpdateFromStatement_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(io.Reader), args[1].(string)) + }) + return _c +} + +func (_c *MockDockerfileManager_UpdateFromStatement_Call) Return(s string, err error) *MockDockerfileManager_UpdateFromStatement_Call { + _c.Call.Return(s, err) + return _c +} + +func (_c *MockDockerfileManager_UpdateFromStatement_Call) RunAndReturn(run func(dockerfile io.Reader, baseImage string) (string, error)) *MockDockerfileManager_UpdateFromStatement_Call { + _c.Call.Return(run) + return _c +} + // NewMockFileIO creates a new instance of MockFileIO. 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 NewMockFileIO(t interface { @@ -408,6 +491,53 @@ func (_c *MockFileIO_ReadDir_Call) RunAndReturn(run func(dirname string) ([]os.D return _c } +// WriteFile provides a mock function for the type MockFileIO +func (_mock *MockFileIO) WriteFile(filename string, data []byte, perm os.FileMode) error { + ret := _mock.Called(filename, data, perm) + + if len(ret) == 0 { + panic("no return value specified for WriteFile") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string, []byte, os.FileMode) error); ok { + r0 = returnFunc(filename, data, perm) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockFileIO_WriteFile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WriteFile' +type MockFileIO_WriteFile_Call struct { + *mock.Call +} + +// WriteFile is a helper method to define mock.On call +// - filename +// - data +// - perm +func (_e *MockFileIO_Expecter) WriteFile(filename interface{}, data interface{}, perm interface{}) *MockFileIO_WriteFile_Call { + return &MockFileIO_WriteFile_Call{Call: _e.mock.On("WriteFile", filename, data, perm)} +} + +func (_c *MockFileIO_WriteFile_Call) Run(run func(filename string, data []byte, perm os.FileMode)) *MockFileIO_WriteFile_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].([]byte), args[2].(os.FileMode)) + }) + return _c +} + +func (_c *MockFileIO_WriteFile_Call) Return(err error) *MockFileIO_WriteFile_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockFileIO_WriteFile_Call) RunAndReturn(run func(filename string, data []byte, perm os.FileMode) error) *MockFileIO_WriteFile_Call { + _c.Call.Return(run) + return _c +} + // NewMockClosableFile creates a new instance of MockClosableFile. 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 NewMockClosableFile(t interface {