diff --git a/cli/cmd/init.go b/cli/cmd/init.go new file mode 100644 index 00000000..f4f383c6 --- /dev/null +++ b/cli/cmd/init.go @@ -0,0 +1,25 @@ +// 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" +) + +type InitCmd struct { + cmd *cobra.Command +} + +func AddInitCmd(rootCmd *cobra.Command, opts *GlobalOptions) { + init := InitCmd{ + cmd: &cobra.Command{ + Use: "init", + Short: "Initialize configuration files", + Long: io.Long(`Initialize configuration files for Codesphere installation and other components.`), + }, + } + rootCmd.AddCommand(init.cmd) + AddInitInstallConfigCmd(init.cmd, opts) +} diff --git a/cli/cmd/init_install_config.go b/cli/cmd/init_install_config.go new file mode 100644 index 00000000..10b6b135 --- /dev/null +++ b/cli/cmd/init_install_config.go @@ -0,0 +1,431 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + "strings" + + csio "github.com/codesphere-cloud/cs-go/pkg/io" + "github.com/codesphere-cloud/oms/internal/installer" + "github.com/codesphere-cloud/oms/internal/installer/files" + "github.com/codesphere-cloud/oms/internal/util" + "github.com/spf13/cobra" +) + +type InitInstallConfigCmd struct { + cmd *cobra.Command + Opts *InitInstallConfigOpts + FileWriter util.FileIO +} + +type InitInstallConfigOpts struct { + *GlobalOptions + + ConfigFile string + VaultFile string + + Profile string + ValidateOnly bool + WithComments bool + Interactive bool + GenerateKeys bool + SecretsBaseDir string + + DatacenterID int + DatacenterName string + DatacenterCity string + DatacenterCountryCode string + + RegistryServer string + RegistryReplaceImagesInBom bool + RegistryLoadContainerImages bool + + PostgresMode string + PostgresPrimaryIP string + PostgresPrimaryHostname string + PostgresReplicaIP string + PostgresReplicaName string + PostgresServerAddress string + + CephNodesSubnet string + CephHosts []files.CephHostConfig + + KubernetesManagedByCodesphere bool + KubernetesAPIServerHost string + KubernetesControlPlanes []string + KubernetesWorkers []string + KubernetesPodCIDR string + KubernetesServiceCIDR string + + ClusterGatewayServiceType string + ClusterGatewayIPAddresses []string + ClusterPublicGatewayServiceType string + ClusterPublicGatewayIPAddresses []string + + MetalLBEnabled bool + MetalLBPools []files.MetalLBPool + + CodesphereDomain string + CodespherePublicIP string + CodesphereWorkspaceHostingBaseDomain string + CodesphereCustomDomainsCNameBaseDomain string + CodesphereDNSServers []string + CodesphereWorkspaceImageBomRef string + CodesphereHostingPlanCPUTenth int + CodesphereHostingPlanMemoryMb int + CodesphereHostingPlanStorageMb int + CodesphereHostingPlanTempStorageMb int + CodesphereWorkspacePlanName string + CodesphereWorkspacePlanMaxReplicas int +} + +func (c *InitInstallConfigCmd) RunE(_ *cobra.Command, args []string) error { + icg := installer.NewInstallConfigManager() + + return c.InitInstallConfig(icg) +} + +func AddInitInstallConfigCmd(init *cobra.Command, opts *GlobalOptions) { + c := InitInstallConfigCmd{ + cmd: &cobra.Command{ + Use: "install-config", + Short: "Initialize Codesphere installer configuration files", + Long: csio.Long(`Initialize config.yaml and prod.vault.yaml for the Codesphere installer. + + This command generates two files: + - config.yaml: Main configuration (infrastructure, networking, plans) + - prod.vault.yaml: Secrets file (keys, certificates, passwords) + + Note: When --interactive=true (default), all other configuration flags are ignored + and you will be prompted for all settings interactively. + + Supports configuration profiles for common scenarios: + - dev: Single-node development setup + - production: HA multi-node setup + - minimal: Minimal testing setup`), + Example: formatExamplesWithBinary("init install-config", []csio.Example{ + {Cmd: "-c config.yaml -v prod.vault.yaml", Desc: "Create config files interactively"}, + {Cmd: "--profile dev -c config.yaml -v prod.vault.yaml", Desc: "Use dev profile with defaults"}, + {Cmd: "--profile production -c config.yaml -v prod.vault.yaml", Desc: "Use production profile"}, + {Cmd: "--validate -c config.yaml -v prod.vault.yaml", Desc: "Validate existing configuration files"}, + }, "oms-cli"), + }, + Opts: &InitInstallConfigOpts{GlobalOptions: opts}, + FileWriter: util.NewFilesystemWriter(), + } + + c.cmd.Flags().StringVarP(&c.Opts.ConfigFile, "config", "c", "config.yaml", "Output file path for config.yaml") + c.cmd.Flags().StringVarP(&c.Opts.VaultFile, "vault", "v", "prod.vault.yaml", "Output file path for prod.vault.yaml") + + c.cmd.Flags().StringVar(&c.Opts.Profile, "profile", "", "Use a predefined configuration profile (dev, production, minimal)") + c.cmd.Flags().BoolVar(&c.Opts.ValidateOnly, "validate", false, "Validate existing config files instead of creating new ones") + c.cmd.Flags().BoolVar(&c.Opts.WithComments, "with-comments", false, "Add helpful comments to the generated YAML files") + c.cmd.Flags().BoolVar(&c.Opts.Interactive, "interactive", true, "Enable interactive prompting (when true, other config flags are ignored)") + c.cmd.Flags().BoolVar(&c.Opts.GenerateKeys, "generate-keys", true, "Generate SSH keys and certificates") + c.cmd.Flags().StringVar(&c.Opts.SecretsBaseDir, "secrets-dir", "/root/secrets", "Secrets base directory") + + c.cmd.Flags().IntVar(&c.Opts.DatacenterID, "dc-id", 0, "Datacenter ID") + c.cmd.Flags().StringVar(&c.Opts.DatacenterName, "dc-name", "", "Datacenter name") + + c.cmd.Flags().StringVar(&c.Opts.PostgresMode, "postgres-mode", "", "PostgreSQL setup mode (install/external)") + c.cmd.Flags().StringVar(&c.Opts.PostgresPrimaryIP, "postgres-primary-ip", "", "Primary PostgreSQL server IP") + + c.cmd.Flags().BoolVar(&c.Opts.KubernetesManagedByCodesphere, "k8s-managed", true, "Use Codesphere-managed Kubernetes") + c.cmd.Flags().StringSliceVar(&c.Opts.KubernetesControlPlanes, "k8s-control-plane", []string{}, "K8s control plane IPs (comma-separated)") + + c.cmd.Flags().StringVar(&c.Opts.CodesphereDomain, "domain", "", "Main Codesphere domain") + + util.MarkFlagRequired(c.cmd, "config") + util.MarkFlagRequired(c.cmd, "vault") + + c.cmd.RunE = c.RunE + init.AddCommand(c.cmd) +} + +func (c *InitInstallConfigCmd) InitInstallConfig(icg installer.InstallConfigManager) error { + if c.Opts.ValidateOnly { + return c.validateOnly(icg) + } + + // Generate new configuration from either Opts or interactively + err := icg.ApplyProfile(c.Opts.Profile) + if err != nil { + return fmt.Errorf("failed to apply profile: %w", err) + } + + c.printWelcomeMessage() + + if c.Opts.Interactive { + err = icg.CollectInteractively() + if err != nil { + return fmt.Errorf("failed to collect configuration interactively: %w", err) + } + } else { + c.updateConfigFromOpts(icg.GetInstallConfig()) + } + + errors := icg.ValidateInstallConfig() + if len(errors) > 0 { + return fmt.Errorf("configuration validation failed: %s", strings.Join(errors, ", ")) + } + + if err := icg.GenerateSecrets(); err != nil { + return fmt.Errorf("failed to generate secrets: %w", err) + } + + if err := icg.WriteInstallConfig(c.Opts.ConfigFile, c.Opts.WithComments); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + + if err := icg.WriteVault(c.Opts.VaultFile, c.Opts.WithComments); err != nil { + return fmt.Errorf("failed to write vault file: %w", err) + } + + c.printSuccessMessage() + + return nil +} + +func (c *InitInstallConfigCmd) printWelcomeMessage() { + fmt.Println("Welcome to OMS!") + fmt.Println("This wizard will help you create config.yaml and prod.vault.yaml for Codesphere installation.") + fmt.Println() +} + +func (c *InitInstallConfigCmd) printSuccessMessage() { + fmt.Println("\n" + strings.Repeat("=", 70)) + fmt.Println("Configuration files successfully generated!") + fmt.Println(strings.Repeat("=", 70)) + + fmt.Println("\nIMPORTANT: Keys and certificates have been generated and embedded in the vault file.") + fmt.Println(" Keep the vault file secure and encrypt it with SOPS before storing.") + + fmt.Println("\nNext steps:") + fmt.Println("1. Review the generated config.yaml and prod.vault.yaml") + fmt.Println("2. Install SOPS and Age: brew install sops age") + fmt.Println("3. Generate an Age keypair: age-keygen -o age_key.txt") + fmt.Println("4. Encrypt the vault file:") + fmt.Printf(" age-keygen -y age_key.txt # Get public key\n") + fmt.Printf(" sops --encrypt --age --in-place %s\n", c.Opts.VaultFile) + fmt.Println("5. Run the Codesphere installer with these configuration files") + fmt.Println() +} + +func (c *InitInstallConfigCmd) validateOnly(icg installer.InstallConfigManager) error { + fmt.Printf("Validating configuration files...\n") + + fmt.Printf("Reading install config file: %s\n", c.Opts.ConfigFile) + err := icg.LoadInstallConfigFromFile(c.Opts.ConfigFile) + if err != nil { + return fmt.Errorf("failed to load config file: %w", err) + } + + errors := icg.ValidateInstallConfig() + if len(errors) > 0 { + return fmt.Errorf("install config validation failed: %s", strings.Join(errors, ", ")) + } + + if c.Opts.VaultFile != "" { + fmt.Printf("Reading vault file: %s\n", c.Opts.VaultFile) + err := icg.LoadVaultFromFile(c.Opts.VaultFile) + if err != nil { + return fmt.Errorf("failed to load vault file: %w", err) + } + + vaultErrors := icg.ValidateVault() + if len(vaultErrors) > 0 { + return fmt.Errorf("vault validation errors: %s", strings.Join(vaultErrors, ", ")) + } + } + + fmt.Println("Configuration is valid!") + return nil +} + +func (c *InitInstallConfigCmd) updateConfigFromOpts(config *files.RootConfig) *files.RootConfig { + // Datacenter settings + if c.Opts.DatacenterID != 0 { + config.Datacenter.ID = c.Opts.DatacenterID + } + if c.Opts.DatacenterCity != "" { + config.Datacenter.City = c.Opts.DatacenterCity + } + if c.Opts.DatacenterCountryCode != "" { + config.Datacenter.CountryCode = c.Opts.DatacenterCountryCode + } + if c.Opts.DatacenterName != "" { + config.Datacenter.Name = c.Opts.DatacenterName + } + + // Registry settings + if c.Opts.RegistryServer != "" { + config.Registry.LoadContainerImages = c.Opts.RegistryLoadContainerImages + config.Registry.ReplaceImagesInBom = c.Opts.RegistryReplaceImagesInBom + config.Registry.Server = c.Opts.RegistryServer + } + + // Postgres settings + if c.Opts.PostgresMode != "" { + config.Postgres.Mode = c.Opts.PostgresMode + } + + if c.Opts.PostgresServerAddress != "" { + config.Postgres.ServerAddress = c.Opts.PostgresServerAddress + } + + if c.Opts.PostgresPrimaryHostname != "" && c.Opts.PostgresPrimaryIP != "" { + if config.Postgres.Primary == nil { + config.Postgres.Primary = &files.PostgresPrimaryConfig{ + Hostname: c.Opts.PostgresPrimaryHostname, + IP: c.Opts.PostgresPrimaryIP, + } + } else { + config.Postgres.Primary.Hostname = c.Opts.PostgresPrimaryHostname + config.Postgres.Primary.IP = c.Opts.PostgresPrimaryIP + } + } + + if c.Opts.PostgresReplicaIP != "" && c.Opts.PostgresReplicaName != "" { + if config.Postgres.Replica == nil { + config.Postgres.Replica = &files.PostgresReplicaConfig{ + Name: c.Opts.PostgresReplicaName, + IP: c.Opts.PostgresReplicaIP, + } + } else { + config.Postgres.Replica.Name = c.Opts.PostgresReplicaName + config.Postgres.Replica.IP = c.Opts.PostgresReplicaIP + } + } + + // Ceph settings + if c.Opts.CephNodesSubnet != "" { + config.Ceph.NodesSubnet = c.Opts.CephNodesSubnet + } + if len(c.Opts.CephHosts) > 0 { + cephHosts := []files.CephHost{} + for _, hostCfg := range c.Opts.CephHosts { + cephHosts = append(cephHosts, files.CephHost(hostCfg)) + } + config.Ceph.Hosts = cephHosts + } + + // Kubernetes settings + if c.Opts.KubernetesAPIServerHost != "" { + config.Kubernetes.APIServerHost = c.Opts.KubernetesAPIServerHost + } + if c.Opts.KubernetesPodCIDR != "" { + config.Kubernetes.PodCIDR = c.Opts.KubernetesPodCIDR + } + if c.Opts.KubernetesServiceCIDR != "" { + config.Kubernetes.ServiceCIDR = c.Opts.KubernetesServiceCIDR + } + + if len(c.Opts.KubernetesControlPlanes) > 0 { + kubernetesControlPlanes := []files.K8sNode{} + for _, ip := range c.Opts.KubernetesControlPlanes { + kubernetesControlPlanes = append(kubernetesControlPlanes, files.K8sNode{ + IPAddress: ip, + }) + } + config.Kubernetes.ControlPlanes = kubernetesControlPlanes + } + + if len(c.Opts.KubernetesWorkers) > 0 { + kubernetesWorkers := []files.K8sNode{} + for _, ip := range c.Opts.KubernetesWorkers { + kubernetesWorkers = append(kubernetesWorkers, files.K8sNode{ + IPAddress: ip, + }) + } + config.Kubernetes.Workers = kubernetesWorkers + } + + // Cluster Gateway settings + if c.Opts.ClusterGatewayServiceType != "" { + config.Cluster.Gateway.ServiceType = c.Opts.ClusterGatewayServiceType + } + if len(c.Opts.ClusterGatewayIPAddresses) > 0 { + config.Cluster.Gateway.IPAddresses = c.Opts.ClusterGatewayIPAddresses + } + if c.Opts.ClusterPublicGatewayServiceType != "" { + config.Cluster.PublicGateway.ServiceType = c.Opts.ClusterPublicGatewayServiceType + } + if len(c.Opts.ClusterPublicGatewayIPAddresses) > 0 { + config.Cluster.PublicGateway.IPAddresses = c.Opts.ClusterPublicGatewayIPAddresses + } + + // MetalLB settings + if c.Opts.MetalLBEnabled { + if config.MetalLB == nil { + config.MetalLB = &files.MetalLBConfig{ + Enabled: c.Opts.MetalLBEnabled, + Pools: []files.MetalLBPoolDef{}, + } + } else { + config.MetalLB.Enabled = c.Opts.MetalLBEnabled + config.MetalLB.Pools = []files.MetalLBPoolDef{} + } + + for _, pool := range c.Opts.MetalLBPools { + config.MetalLB.Pools = append(config.MetalLB.Pools, files.MetalLBPoolDef(pool)) + } + } + + // Codesphere settings + if c.Opts.CodesphereDomain != "" { + config.Codesphere.Domain = c.Opts.CodesphereDomain + } + if c.Opts.CodespherePublicIP != "" { + config.Codesphere.PublicIP = c.Opts.CodespherePublicIP + } + if c.Opts.CodesphereWorkspaceHostingBaseDomain != "" { + config.Codesphere.WorkspaceHostingBaseDomain = c.Opts.CodesphereWorkspaceHostingBaseDomain + } + if c.Opts.CodesphereCustomDomainsCNameBaseDomain != "" { + config.Codesphere.CustomDomains = files.CustomDomainsConfig{CNameBaseDomain: c.Opts.CodesphereCustomDomainsCNameBaseDomain} + } + if len(c.Opts.CodesphereDNSServers) > 0 { + config.Codesphere.DNSServers = c.Opts.CodesphereDNSServers + } + + if c.Opts.CodesphereWorkspaceImageBomRef != "" { + if config.Codesphere.WorkspaceImages == nil { + config.Codesphere.WorkspaceImages = &files.WorkspaceImagesConfig{} + } + config.Codesphere.WorkspaceImages.Agent = &files.ImageRef{ + BomRef: c.Opts.CodesphereWorkspaceImageBomRef, + } + } + + // Plans + if c.Opts.CodesphereHostingPlanCPUTenth != 0 || c.Opts.CodesphereHostingPlanMemoryMb != 0 || + c.Opts.CodesphereHostingPlanStorageMb != 0 || c.Opts.CodesphereHostingPlanTempStorageMb != 0 { + config.Codesphere.Plans = files.PlansConfig{ + HostingPlans: map[int]files.HostingPlan{ + 1: { + CPUTenth: c.Opts.CodesphereHostingPlanCPUTenth, + MemoryMb: c.Opts.CodesphereHostingPlanMemoryMb, + StorageMb: c.Opts.CodesphereHostingPlanStorageMb, + TempStorageMb: c.Opts.CodesphereHostingPlanTempStorageMb, + }, + }, + WorkspacePlans: map[int]files.WorkspacePlan{ + 1: { + Name: c.Opts.CodesphereWorkspacePlanName, + HostingPlanID: 1, + MaxReplicas: c.Opts.CodesphereWorkspacePlanMaxReplicas, + OnDemand: true, + }, + }, + } + } + + // Secrets base dir + if c.Opts.SecretsBaseDir != "" && c.Opts.SecretsBaseDir != "/root/secrets" { + config.Secrets.BaseDir = c.Opts.SecretsBaseDir + } + + return config +} diff --git a/cli/cmd/init_install_config_interactive_test.go b/cli/cmd/init_install_config_interactive_test.go new file mode 100644 index 00000000..a03fe7ff --- /dev/null +++ b/cli/cmd/init_install_config_interactive_test.go @@ -0,0 +1,156 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "os" + + . "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("Interactive profile usage", func() { + Context("when using profile with interactive mode", func() { + It("should use profile values as defaults", func() { + icg := installer.NewInstallConfigManager() + + // Apply dev profile first (like the command does) + err := icg.ApplyProfile("dev") + Expect(err).NotTo(HaveOccurred()) + + config := icg.GetInstallConfig() + + // Verify that profile values are set correctly + Expect(config.Datacenter.ID).To(Equal(1)) + Expect(config.Datacenter.Name).To(Equal("dev")) + Expect(config.Datacenter.City).To(Equal("Karlsruhe")) + Expect(config.Datacenter.CountryCode).To(Equal("DE")) + + // Postgres should be set to install mode with localhost + Expect(config.Postgres.Mode).To(Equal("install")) + Expect(config.Postgres.Primary).NotTo(BeNil()) + Expect(config.Postgres.Primary.IP).To(Equal("127.0.0.1")) + Expect(config.Postgres.Primary.Hostname).To(Equal("localhost")) + + // Ceph should be configured for localhost + Expect(config.Ceph.NodesSubnet).To(Equal("127.0.0.1/32")) + Expect(config.Ceph.Hosts).To(HaveLen(1)) + Expect(config.Ceph.Hosts[0].Hostname).To(Equal("localhost")) + Expect(config.Ceph.Hosts[0].IPAddress).To(Equal("127.0.0.1")) + Expect(config.Ceph.Hosts[0].IsMaster).To(BeTrue()) + + // Kubernetes should be managed and use localhost + Expect(config.Kubernetes.ManagedByCodesphere).To(BeTrue()) + Expect(config.Kubernetes.APIServerHost).To(Equal("127.0.0.1")) + Expect(config.Kubernetes.ControlPlanes).To(HaveLen(1)) + Expect(config.Kubernetes.ControlPlanes[0].IPAddress).To(Equal("127.0.0.1")) + Expect(config.Kubernetes.Workers).To(HaveLen(1)) + Expect(config.Kubernetes.Workers[0].IPAddress).To(Equal("127.0.0.1")) + + // Codesphere domain should be set + Expect(config.Codesphere.Domain).To(Equal("codesphere.local")) + Expect(config.Codesphere.WorkspaceHostingBaseDomain).To(Equal("ws.local")) + Expect(config.Codesphere.CustomDomains.CNameBaseDomain).To(Equal("custom.local")) + }) + + It("should allow non-interactive collection to use profile defaults", func() { + icg := installer.NewInstallConfigManager() + + // Apply dev profile + err := icg.ApplyProfile("dev") + Expect(err).NotTo(HaveOccurred()) + + // In non-interactive mode, CollectInteractively would use defaults + // We simulate this by checking that the prompter returns defaults + // when interactive=false + prompter := installer.NewPrompter(false) + + // Test that prompter returns defaults when not interactive + Expect(prompter.String("Test", "default-value")).To(Equal("default-value")) + Expect(prompter.Int("Test", 42)).To(Equal(42)) + Expect(prompter.Bool("Test", true)).To(BeTrue()) + Expect(prompter.Choice("Test", []string{"a", "b"}, "a")).To(Equal("a")) + Expect(prompter.StringSlice("Test", []string{"1", "2"})).To(Equal([]string{"1", "2"})) + }) + + It("should generate valid config files with profile", func() { + configFile, err := os.CreateTemp("", "config-*.yaml") + Expect(err).NotTo(HaveOccurred()) + defer func() { _ = os.Remove(configFile.Name()) }() + err = configFile.Close() + Expect(err).NotTo(HaveOccurred()) + + vaultFile, err := os.CreateTemp("", "vault-*.yaml") + Expect(err).NotTo(HaveOccurred()) + defer func() { _ = os.Remove(vaultFile.Name()) }() + err = vaultFile.Close() + Expect(err).NotTo(HaveOccurred()) + + c := &InitInstallConfigCmd{ + Opts: &InitInstallConfigOpts{ + GlobalOptions: &GlobalOptions{}, + ConfigFile: configFile.Name(), + VaultFile: vaultFile.Name(), + Profile: "dev", + Interactive: false, // Non-interactive to avoid stdin issues + }, + FileWriter: util.NewFilesystemWriter(), + } + + icg := installer.NewInstallConfigManager() + err = c.InitInstallConfig(icg) + Expect(err).NotTo(HaveOccurred()) + + // Verify files were created + _, err = os.Stat(configFile.Name()) + Expect(err).NotTo(HaveOccurred()) + + _, err = os.Stat(vaultFile.Name()) + Expect(err).NotTo(HaveOccurred()) + + // Verify config content + err = icg.LoadInstallConfigFromFile(configFile.Name()) + Expect(err).NotTo(HaveOccurred()) + + config := icg.GetInstallConfig() + Expect(config.Datacenter.Name).To(Equal("dev")) + Expect(config.Codesphere.Domain).To(Equal("codesphere.local")) + }) + }) + + Context("when using production profile", func() { + It("should set production-specific defaults", func() { + icg := installer.NewInstallConfigManager() + + err := icg.ApplyProfile("production") + Expect(err).NotTo(HaveOccurred()) + + config := icg.GetInstallConfig() + + // Verify production-specific values + Expect(config.Datacenter.Name).To(Equal("production")) + Expect(config.Postgres.Primary.IP).To(Equal("10.50.0.2")) + Expect(config.Postgres.Replica).NotTo(BeNil()) + Expect(config.Postgres.Replica.IP).To(Equal("10.50.0.3")) + + // Ceph should have 3 nodes + Expect(config.Ceph.Hosts).To(HaveLen(3)) + Expect(config.Ceph.Hosts[0].Hostname).To(Equal("ceph-node-0")) + Expect(config.Ceph.Hosts[0].IPAddress).To(Equal("10.53.101.2")) + Expect(config.Ceph.Hosts[0].IsMaster).To(BeTrue()) + + Expect(config.Ceph.Hosts[1].Hostname).To(Equal("ceph-node-1")) + Expect(config.Ceph.Hosts[1].IsMaster).To(BeFalse()) + + // Kubernetes should have multiple workers + Expect(config.Kubernetes.ControlPlanes).To(HaveLen(1)) + Expect(config.Kubernetes.Workers).To(HaveLen(3)) + + Expect(config.Codesphere.Domain).To(Equal("codesphere.yourcompany.com")) + }) + }) +}) diff --git a/cli/cmd/init_install_config_test.go b/cli/cmd/init_install_config_test.go new file mode 100644 index 00000000..e015b765 --- /dev/null +++ b/cli/cmd/init_install_config_test.go @@ -0,0 +1,302 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "os" + + . "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("ApplyProfile", func() { + DescribeTable("profile application", + func(profile string, wantErr bool, checkDatacenterName string) { + icg := installer.NewInstallConfigManager() + + err := icg.ApplyProfile(profile) + if wantErr { + Expect(err).To(HaveOccurred()) + } else { + Expect(err).NotTo(HaveOccurred()) + config := icg.GetInstallConfig() + Expect(config.Datacenter.Name).To(Equal(checkDatacenterName)) + } + }, + Entry("dev profile", "dev", false, "dev"), + Entry("development profile", "development", false, "dev"), + Entry("prod profile", "prod", false, "production"), + Entry("production profile", "production", false, "production"), + Entry("minimal profile", "minimal", false, "minimal"), + Entry("invalid profile", "invalid", true, ""), + ) + + Context("dev profile details", func() { + It("sets correct dev profile configuration", func() { + icg := installer.NewInstallConfigManager() + + err := icg.ApplyProfile("dev") + Expect(err).NotTo(HaveOccurred()) + config := icg.GetInstallConfig() + Expect(config.Datacenter.ID).To(Equal(1)) + Expect(config.Datacenter.Name).To(Equal("dev")) + Expect(config.Postgres.Mode).To(Equal("install")) + Expect(config.Kubernetes.ManagedByCodesphere).To(BeTrue()) + }) + }) +}) + +var _ = Describe("ValidateConfig", func() { + var ( + configFile *os.File + vaultFile *os.File + validConfig string + validVault string + ) + + BeforeEach(func() { + var err error + configFile, err = os.CreateTemp("", "config-*.yaml") + Expect(err).NotTo(HaveOccurred()) + + vaultFile, err = os.CreateTemp("", "vault-*.yaml") + Expect(err).NotTo(HaveOccurred()) + + validConfig = `dataCenter: + id: 1 + name: test + city: Berlin + countryCode: DE +secrets: + baseDir: /root/secrets +postgres: + mode: external + serverAddress: postgres.example.com:5432 +ceph: + cephAdmSshKey: + publicKey: ssh-rsa TEST + nodesSubnet: 10.53.101.0/24 + hosts: + - hostname: ceph-1 + ipAddress: 10.53.101.2 + isMaster: true + osds: [] +kubernetes: + managedByCodesphere: false + podCidr: 100.96.0.0/11 + serviceCidr: 100.64.0.0/13 +cluster: + certificates: + ca: + algorithm: RSA + keySizeBits: 2048 + certPem: "-----BEGIN CERTIFICATE-----\nTEST\n-----END CERTIFICATE-----" + gateway: + serviceType: LoadBalancer + publicGateway: + serviceType: LoadBalancer +codesphere: + domain: codesphere.example.com + workspaceHostingBaseDomain: ws.example.com + publicIp: 1.2.3.4 + customDomains: + cNameBaseDomain: custom.example.com + dnsServers: + - 8.8.8.8 + experiments: [] + deployConfig: + images: + ubuntu-24.04: + name: Ubuntu 24.04 + supportedUntil: "2028-05-31" + flavors: + default: + image: + bomRef: workspace-agent-24.04 + pool: + 1: 1 + plans: + hostingPlans: + 1: + cpuTenth: 10 + gpuParts: 0 + memoryMb: 2048 + storageMb: 20480 + tempStorageMb: 1024 + workspacePlans: + 1: + name: Standard + hostingPlanId: 1 + maxReplicas: 3 + onDemand: true +` + + validVault = `secrets: + - name: cephSshPrivateKey + file: + name: id_rsa + content: "-----BEGIN RSA PRIVATE KEY-----\nTEST\n-----END RSA PRIVATE KEY-----" + - name: selfSignedCaKeyPem + file: + name: key.pem + content: "-----BEGIN RSA PRIVATE KEY-----\nCA\n-----END RSA PRIVATE KEY-----" + - name: domainAuthPrivateKey + file: + name: key.pem + content: "-----BEGIN EC PRIVATE KEY-----\nDOMAIN\n-----END EC PRIVATE KEY-----" + - name: domainAuthPublicKey + file: + name: key.pem + content: "-----BEGIN PUBLIC KEY-----\nDOMAIN-PUB\n-----END PUBLIC KEY-----" +` + }) + + AfterEach(func() { + _ = os.Remove(configFile.Name()) + _ = os.Remove(vaultFile.Name()) + }) + + Context("valid configuration", func() { + It("validates successfully", func() { + _, err := configFile.WriteString(validConfig) + Expect(err).NotTo(HaveOccurred()) + err = configFile.Close() + Expect(err).NotTo(HaveOccurred()) + + _, err = vaultFile.WriteString(validVault) + Expect(err).NotTo(HaveOccurred()) + err = vaultFile.Close() + Expect(err).NotTo(HaveOccurred()) + + c := &InitInstallConfigCmd{ + Opts: &InitInstallConfigOpts{ + ConfigFile: configFile.Name(), + VaultFile: vaultFile.Name(), + ValidateOnly: true, + }, + FileWriter: util.NewFilesystemWriter(), + } + + icg := installer.NewInstallConfigManager() + err = c.validateOnly(icg) + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Context("invalid datacenter", func() { + It("fails validation", func() { + invalidConfig := `dataCenter: + id: 0 + name: "" +secrets: + baseDir: /root/secrets +postgres: + serverAddress: postgres.example.com:5432 +ceph: + hosts: [] +kubernetes: + managedByCodesphere: true +cluster: + certificates: + ca: + algorithm: RSA + keySizeBits: 2048 + certPem: "-----BEGIN CERTIFICATE-----\nTEST\n-----END CERTIFICATE-----" + gateway: + serviceType: LoadBalancer + publicGateway: + serviceType: LoadBalancer +codesphere: + domain: "" + deployConfig: + images: {} + plans: + hostingPlans: {} + workspacePlans: {} +` + + _, err := configFile.WriteString(invalidConfig) + Expect(err).NotTo(HaveOccurred()) + err = configFile.Close() + Expect(err).NotTo(HaveOccurred()) + + c := &InitInstallConfigCmd{ + Opts: &InitInstallConfigOpts{ + ConfigFile: configFile.Name(), + ValidateOnly: true, + }, + FileWriter: util.NewFilesystemWriter(), + } + + icg := installer.NewInstallConfigManager() + err = c.validateOnly(icg) + Expect(err).To(HaveOccurred()) + }) + }) + + Context("invalid IP address", func() { + It("fails validation", func() { + configWithInvalidIP := `dataCenter: + id: 1 + name: test + city: Berlin + countryCode: DE +secrets: + baseDir: /root/secrets +postgres: + serverAddress: postgres.example.com:5432 +ceph: + cephAdmSshKey: + publicKey: ssh-rsa TEST + nodesSubnet: 10.53.101.0/24 + hosts: + - hostname: ceph-1 + ipAddress: invalid-ip-address + isMaster: true + osds: [] +kubernetes: + managedByCodesphere: true + controlPlanes: + - ipAddress: 10.0.0.1 +cluster: + certificates: + ca: + algorithm: RSA + keySizeBits: 2048 + certPem: "-----BEGIN CERTIFICATE-----\nTEST\n-----END CERTIFICATE-----" + gateway: + serviceType: LoadBalancer + publicGateway: + serviceType: LoadBalancer +codesphere: + domain: codesphere.example.com + deployConfig: + images: {} + plans: + hostingPlans: {} + workspacePlans: {} +` + + _, err := configFile.WriteString(configWithInvalidIP) + Expect(err).NotTo(HaveOccurred()) + err = configFile.Close() + Expect(err).NotTo(HaveOccurred()) + + c := &InitInstallConfigCmd{ + Opts: &InitInstallConfigOpts{ + ConfigFile: configFile.Name(), + ValidateOnly: true, + }, + FileWriter: util.NewFilesystemWriter(), + } + + icg := installer.NewInstallConfigManager() + err = c.validateOnly(icg) + Expect(err).To(HaveOccurred()) + }) + }) +}) diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 431e5cd9..b3a1108b 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -35,6 +35,7 @@ func GetRootCmd() *cobra.Command { AddListCmd(rootCmd, opts) AddDownloadCmd(rootCmd, opts) AddInstallCmd(rootCmd, opts) + AddInitCmd(rootCmd, opts) AddBuildCmd(rootCmd, opts) AddLicensesCmd(rootCmd) diff --git a/docs/README.md b/docs/README.md index a172e71e..64087115 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,6 +20,7 @@ like downloading new versions. * [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 init](oms-cli_init.md) - Initialize configuration files * [oms-cli install](oms-cli_install.md) - Coming soon: Install Codesphere and other components * [oms-cli licenses](oms-cli_licenses.md) - Print license information * [oms-cli list](oms-cli_list.md) - List resources available through OMS diff --git a/docs/oms-cli.md b/docs/oms-cli.md index a172e71e..64087115 100644 --- a/docs/oms-cli.md +++ b/docs/oms-cli.md @@ -20,6 +20,7 @@ like downloading new versions. * [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 init](oms-cli_init.md) - Initialize configuration files * [oms-cli install](oms-cli_install.md) - Coming soon: Install Codesphere and other components * [oms-cli licenses](oms-cli_licenses.md) - Print license information * [oms-cli list](oms-cli_list.md) - List resources available through OMS diff --git a/docs/oms-cli_init.md b/docs/oms-cli_init.md new file mode 100644 index 00000000..08b8b3bb --- /dev/null +++ b/docs/oms-cli_init.md @@ -0,0 +1,19 @@ +## oms-cli init + +Initialize configuration files + +### Synopsis + +Initialize configuration files for Codesphere installation and other components. + +### Options + +``` + -h, --help help for init +``` + +### SEE ALSO + +* [oms-cli](oms-cli.md) - Codesphere Operations Management System (OMS) +* [oms-cli init install-config](oms-cli_init_install-config.md) - Initialize Codesphere installer configuration files + diff --git a/docs/oms-cli_init_install-config.md b/docs/oms-cli_init_install-config.md new file mode 100644 index 00000000..72a49d44 --- /dev/null +++ b/docs/oms-cli_init_install-config.md @@ -0,0 +1,66 @@ +## oms-cli init install-config + +Initialize Codesphere installer configuration files + +### Synopsis + +Initialize config.yaml and prod.vault.yaml for the Codesphere installer. + +This command generates two files: +- config.yaml: Main configuration (infrastructure, networking, plans) +- prod.vault.yaml: Secrets file (keys, certificates, passwords) + +Note: When --interactive=true (default), all other configuration flags are ignored +and you will be prompted for all settings interactively. + +Supports configuration profiles for common scenarios: +- dev: Single-node development setup +- production: HA multi-node setup +- minimal: Minimal testing setup + +``` +oms-cli init install-config [flags] +``` + +### Examples + +``` +# Create config files interactively +$ oms-cli init install-config -c config.yaml -v prod.vault.yaml + +# Use dev profile with defaults +$ oms-cli init install-config --profile dev -c config.yaml -v prod.vault.yaml + +# Use production profile +$ oms-cli init install-config --profile production -c config.yaml -v prod.vault.yaml + +# Validate existing configuration files +$ oms-cli init install-config --validate -c config.yaml -v prod.vault.yaml + +``` + +### Options + +``` + -c, --config string Output file path for config.yaml (default "config.yaml") + --dc-id int Datacenter ID + --dc-name string Datacenter name + --domain string Main Codesphere domain + --generate-keys Generate SSH keys and certificates (default true) + -h, --help help for install-config + --interactive Enable interactive prompting (when true, other config flags are ignored) (default true) + --k8s-control-plane strings K8s control plane IPs (comma-separated) + --k8s-managed Use Codesphere-managed Kubernetes (default true) + --postgres-mode string PostgreSQL setup mode (install/external) + --postgres-primary-ip string Primary PostgreSQL server IP + --profile string Use a predefined configuration profile (dev, production, minimal) + --secrets-dir string Secrets base directory (default "/root/secrets") + --validate Validate existing config files instead of creating new ones + -v, --vault string Output file path for prod.vault.yaml (default "prod.vault.yaml") + --with-comments Add helpful comments to the generated YAML files +``` + +### SEE ALSO + +* [oms-cli init](oms-cli_init.md) - Initialize configuration files + diff --git a/internal/installer/config.go b/internal/installer/config.go index 45c96761..f7bcc14c 100644 --- a/internal/installer/config.go +++ b/internal/installer/config.go @@ -5,6 +5,7 @@ package installer import ( "fmt" + "io" "github.com/codesphere-cloud/oms/internal/installer/files" "github.com/codesphere-cloud/oms/internal/util" @@ -27,8 +28,19 @@ func NewConfig() *Config { // 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) + + file, err := c.FileIO.Open(configPath) + if err != nil { + return rootConfig, fmt.Errorf("failed to open config file: %w", err) + } + defer util.CloseFileIgnoreError(file) + + data, err := io.ReadAll(file) if err != nil { + return rootConfig, fmt.Errorf("failed to read config file: %w", err) + } + + if err := rootConfig.Unmarshal(data); err != nil { return rootConfig, fmt.Errorf("failed to parse config.yaml: %w", err) } diff --git a/internal/installer/config_generator_collector.go b/internal/installer/config_generator_collector.go new file mode 100644 index 00000000..0d13df77 --- /dev/null +++ b/internal/installer/config_generator_collector.go @@ -0,0 +1,295 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "fmt" + + "github.com/codesphere-cloud/oms/internal/installer/files" +) + +func (g *InstallConfig) CollectInteractively() error { + prompter := NewPrompter(true) + + g.collectDatacenterConfig(prompter) + g.collectRegistryConfig(prompter) + g.collectPostgresConfig(prompter) + g.collectCephConfig(prompter) + g.collectK8sConfig(prompter) + g.collectGatewayConfig(prompter) + g.collectMetalLBConfig(prompter) + g.collectCodesphereConfig(prompter) + + return nil +} + +func collectField[T any](isEmpty func(T) bool, promptFunc func() T) T { + return promptFunc() +} + +func isEmptyString(s string) bool { return s == "" } +func isEmptyInt(i int) bool { return i == 0 } +func isEmptySlice(s []string) bool { return len(s) == 0 } + +func (g *InstallConfig) collectString(prompter *Prompter, prompt, defaultVal string) string { + return collectField(isEmptyString, func() string { + return prompter.String(prompt, defaultVal) + }) +} + +func (g *InstallConfig) collectInt(prompter *Prompter, prompt string, defaultVal int) int { + return collectField(isEmptyInt, func() int { + return prompter.Int(prompt, defaultVal) + }) +} + +func (g *InstallConfig) collectStringSlice(prompter *Prompter, prompt string, defaultVal []string) []string { + return collectField(isEmptySlice, func() []string { + return prompter.StringSlice(prompt, defaultVal) + }) +} + +func (g *InstallConfig) collectChoice(prompter *Prompter, prompt string, options []string, defaultVal string) string { + return collectField(isEmptyString, func() string { + return prompter.Choice(prompt, options, defaultVal) + }) +} + +func k8sNodesToStringSlice(nodes []files.K8sNode) []string { + ips := make([]string, len(nodes)) + for i, node := range nodes { + ips[i] = node.IPAddress + } + return ips +} + +func stringSliceToK8sNodes(ips []string) []files.K8sNode { + nodes := make([]files.K8sNode, len(ips)) + for i, ip := range ips { + nodes[i] = files.K8sNode{IPAddress: ip} + } + return nodes +} + +func (g *InstallConfig) collectDatacenterConfig(prompter *Prompter) { + fmt.Println("=== Datacenter Configuration ===") + g.Config.Datacenter.ID = g.collectInt(prompter, "Datacenter ID", g.Config.Datacenter.ID) + g.Config.Datacenter.Name = g.collectString(prompter, "Datacenter name", g.Config.Datacenter.Name) + g.Config.Datacenter.City = g.collectString(prompter, "Datacenter city", g.Config.Datacenter.City) + g.Config.Datacenter.CountryCode = g.collectString(prompter, "Country code", g.Config.Datacenter.CountryCode) + g.Config.Secrets.BaseDir = g.collectString(prompter, "Secrets base directory", "/root/secrets") +} + +func (g *InstallConfig) collectRegistryConfig(prompter *Prompter) { + fmt.Println("\n=== Container Registry Configuration ===") + g.Config.Registry.Server = g.collectString(prompter, "Container registry server (e.g., ghcr.io, leave empty to skip)", "") + if g.Config.Registry.Server != "" { + g.Config.Registry.ReplaceImagesInBom = prompter.Bool("Replace images in BOM", g.Config.Registry.ReplaceImagesInBom) + g.Config.Registry.LoadContainerImages = prompter.Bool("Load container images from installer", g.Config.Registry.LoadContainerImages) + } +} + +func (g *InstallConfig) collectPostgresConfig(prompter *Prompter) { + fmt.Println("\n=== PostgreSQL Configuration ===") + g.Config.Postgres.Mode = g.collectChoice(prompter, "PostgreSQL setup", []string{"install", "external"}, "install") + + if g.Config.Postgres.Mode == "install" { + if g.Config.Postgres.Primary == nil { + g.Config.Postgres.Primary = &files.PostgresPrimaryConfig{} + } + defaultPrimaryIP := g.Config.Postgres.Primary.IP + if defaultPrimaryIP == "" { + defaultPrimaryIP = "10.50.0.2" + } + defaultPrimaryHostname := g.Config.Postgres.Primary.Hostname + if defaultPrimaryHostname == "" { + defaultPrimaryHostname = "pg-primary-node" + } + g.Config.Postgres.Primary.IP = g.collectString(prompter, "Primary PostgreSQL server IP", defaultPrimaryIP) + g.Config.Postgres.Primary.Hostname = g.collectString(prompter, "Primary PostgreSQL hostname", defaultPrimaryHostname) + + hasReplica := prompter.Bool("Configure PostgreSQL replica", g.Config.Postgres.Replica != nil) + if hasReplica { + if g.Config.Postgres.Replica == nil { + g.Config.Postgres.Replica = &files.PostgresReplicaConfig{} + } + g.Config.Postgres.Replica.IP = g.collectString(prompter, "Replica PostgreSQL server IP", "10.50.0.3") + g.Config.Postgres.Replica.Name = g.collectString(prompter, "Replica name (lowercase alphanumeric + underscore only)", "replica1") + } + } else { + g.Config.Postgres.ServerAddress = g.collectString(prompter, "External PostgreSQL server address", "postgres.example.com:5432") + } +} + +func (g *InstallConfig) collectCephConfig(prompter *Prompter) { + fmt.Println("\n=== Ceph Configuration ===") + g.Config.Ceph.NodesSubnet = g.collectString(prompter, "Ceph nodes subnet (CIDR)", "10.53.101.0/24") + + if len(g.Config.Ceph.Hosts) == 0 { + numHosts := prompter.Int("Number of Ceph hosts", 3) + g.Config.Ceph.Hosts = make([]files.CephHost, numHosts) + for i := 0; i < numHosts; i++ { + fmt.Printf("\nCeph Host %d:\n", i+1) + g.Config.Ceph.Hosts[i].Hostname = prompter.String(" Hostname (as shown by 'hostname' command)", fmt.Sprintf("ceph-node-%d", i)) + g.Config.Ceph.Hosts[i].IPAddress = prompter.String(" IP address", fmt.Sprintf("10.53.101.%d", i+2)) + g.Config.Ceph.Hosts[i].IsMaster = (i == 0) + } + } else { + existingHosts := g.Config.Ceph.Hosts + g.Config.Ceph.Hosts = make([]files.CephHost, len(existingHosts)) + for i, host := range existingHosts { + g.Config.Ceph.Hosts[i] = files.CephHost(host) + } + } +} + +func (g *InstallConfig) collectK8sConfig(prompter *Prompter) { + fmt.Println("\n=== Kubernetes Configuration ===") + g.Config.Kubernetes.ManagedByCodesphere = prompter.Bool("Use Codesphere-managed Kubernetes (k0s)", g.Config.Kubernetes.ManagedByCodesphere) + + if g.Config.Kubernetes.ManagedByCodesphere { + defaultAPIServerHost := g.Config.Kubernetes.APIServerHost + if defaultAPIServerHost == "" { + defaultAPIServerHost = "10.50.0.2" + } + g.Config.Kubernetes.APIServerHost = g.collectString(prompter, "Kubernetes API server host (LB/DNS/IP)", defaultAPIServerHost) + + defaultControlPlanes := k8sNodesToStringSlice(g.Config.Kubernetes.ControlPlanes) + if len(defaultControlPlanes) == 0 { + defaultControlPlanes = []string{"10.50.0.2"} + } + defaultWorkers := k8sNodesToStringSlice(g.Config.Kubernetes.Workers) + + controlPlaneIPs := g.collectStringSlice(prompter, "Control plane IP addresses (comma-separated)", defaultControlPlanes) + workerIPs := g.collectStringSlice(prompter, "Worker node IP addresses (comma-separated)", defaultWorkers) + + g.Config.Kubernetes.ControlPlanes = stringSliceToK8sNodes(controlPlaneIPs) + g.Config.Kubernetes.Workers = stringSliceToK8sNodes(workerIPs) + g.Config.Kubernetes.NeedsKubeConfig = false + } else { + g.Config.Kubernetes.PodCIDR = g.collectString(prompter, "Pod CIDR of external cluster", "100.96.0.0/11") + g.Config.Kubernetes.ServiceCIDR = g.collectString(prompter, "Service CIDR of external cluster", "100.64.0.0/13") + g.Config.Kubernetes.NeedsKubeConfig = true + fmt.Println("Note: You'll need to provide kubeconfig in the vault file for external Kubernetes") + } +} + +func (g *InstallConfig) collectGatewayConfig(prompter *Prompter) { + fmt.Println("\n=== Cluster Gateway Configuration ===") + g.Config.Cluster.Gateway.ServiceType = g.collectChoice(prompter, "Gateway service type", []string{"LoadBalancer", "ExternalIP"}, "LoadBalancer") + if g.Config.Cluster.Gateway.ServiceType == "ExternalIP" { + g.Config.Cluster.Gateway.IPAddresses = g.collectStringSlice(prompter, "Gateway IP addresses (comma-separated)", []string{"10.51.0.2", "10.51.0.3"}) + } + + g.Config.Cluster.PublicGateway.ServiceType = g.collectChoice(prompter, "Public gateway service type", []string{"LoadBalancer", "ExternalIP"}, "LoadBalancer") + if g.Config.Cluster.PublicGateway.ServiceType == "ExternalIP" { + g.Config.Cluster.PublicGateway.IPAddresses = g.collectStringSlice(prompter, "Public gateway IP addresses (comma-separated)", []string{"10.52.0.2", "10.52.0.3"}) + } +} + +func (g *InstallConfig) collectMetalLBConfig(prompter *Prompter) { + fmt.Println("\n=== MetalLB Configuration (Optional) ===") + + g.Config.MetalLB.Enabled = prompter.Bool("Enable MetalLB", g.Config.MetalLB.Enabled) + + if g.Config.MetalLB.Enabled { + defaultNumPools := len(g.Config.MetalLB.Pools) + if defaultNumPools == 0 { + defaultNumPools = 1 + } + numPools := prompter.Int("Number of MetalLB IP pools", defaultNumPools) + + g.Config.MetalLB.Pools = make([]files.MetalLBPoolDef, numPools) + for i := 0; i < numPools; i++ { + fmt.Printf("\nMetalLB Pool %d:\n", i+1) + + defaultName := fmt.Sprintf("pool-%d", i+1) + var defaultIPs []string + if i < len(g.Config.MetalLB.Pools) { + defaultName = g.Config.MetalLB.Pools[i].Name + defaultIPs = g.Config.MetalLB.Pools[i].IPAddresses + } + if len(defaultIPs) == 0 { + defaultIPs = []string{"10.10.10.100-10.10.10.200"} + } + + poolName := prompter.String(" Pool name", defaultName) + poolIPs := prompter.StringSlice(" IP addresses/ranges (comma-separated)", defaultIPs) + g.Config.MetalLB.Pools[i] = files.MetalLBPoolDef{ + Name: poolName, + IPAddresses: poolIPs, + } + } + } +} + +func (g *InstallConfig) collectCodesphereConfig(prompter *Prompter) { + fmt.Println("\n=== Codesphere Application Configuration ===") + defaultDomain := g.Config.Codesphere.Domain + if defaultDomain == "" { + defaultDomain = "codesphere.yourcompany.com" + } + defaultWorkspaceDomain := g.Config.Codesphere.WorkspaceHostingBaseDomain + if defaultWorkspaceDomain == "" { + defaultWorkspaceDomain = "ws.yourcompany.com" + } + defaultCustomDomain := g.Config.Codesphere.CustomDomains.CNameBaseDomain + if defaultCustomDomain == "" { + defaultCustomDomain = "custom.yourcompany.com" + } + defaultDNSServers := g.Config.Codesphere.DNSServers + if len(defaultDNSServers) == 0 { + defaultDNSServers = []string{"1.1.1.1", "8.8.8.8"} + } + g.Config.Codesphere.Domain = g.collectString(prompter, "Main Codesphere domain", defaultDomain) + g.Config.Codesphere.WorkspaceHostingBaseDomain = g.collectString(prompter, "Workspace base domain (*.domain should point to public gateway)", defaultWorkspaceDomain) + g.Config.Codesphere.PublicIP = g.collectString(prompter, "Primary public IP for workspaces", "") + g.Config.Codesphere.CustomDomains.CNameBaseDomain = g.collectString(prompter, "Custom domain CNAME base", defaultCustomDomain) + g.Config.Codesphere.DNSServers = g.collectStringSlice(prompter, "DNS servers (comma-separated)", defaultDNSServers) + + fmt.Println("\n=== Workspace Plans Configuration ===") + + if g.Config.Codesphere.WorkspaceImages == nil { + g.Config.Codesphere.WorkspaceImages = &files.WorkspaceImagesConfig{} + } + if g.Config.Codesphere.WorkspaceImages.Agent == nil { + g.Config.Codesphere.WorkspaceImages.Agent = &files.ImageRef{} + } + + defaultBomRef := g.Config.Codesphere.WorkspaceImages.Agent.BomRef + if defaultBomRef == "" { + defaultBomRef = "workspace-agent-24.04" + } + g.Config.Codesphere.WorkspaceImages.Agent.BomRef = g.collectString(prompter, "Workspace agent image BOM reference", defaultBomRef) + hostingPlan := files.HostingPlan{} + hostingPlan.CPUTenth = g.collectInt(prompter, "Hosting plan CPU (tenths, e.g., 10 = 1 core)", 10) + hostingPlan.MemoryMb = g.collectInt(prompter, "Hosting plan memory (MB)", 2048) + hostingPlan.StorageMb = g.collectInt(prompter, "Hosting plan storage (MB)", 20480) + hostingPlan.TempStorageMb = g.collectInt(prompter, "Hosting plan temp storage (MB)", 1024) + + workspacePlan := files.WorkspacePlan{ + HostingPlanID: 1, + } + defaultWorkspacePlanName := "Standard Developer" + defaultMaxReplicas := 3 + if existingPlan, ok := g.Config.Codesphere.Plans.WorkspacePlans[1]; ok { + if existingPlan.Name != "" { + defaultWorkspacePlanName = existingPlan.Name + } + if existingPlan.MaxReplicas > 0 { + defaultMaxReplicas = existingPlan.MaxReplicas + } + } + workspacePlan.Name = g.collectString(prompter, "Workspace plan name", defaultWorkspacePlanName) + workspacePlan.MaxReplicas = g.collectInt(prompter, "Max replicas per workspace", defaultMaxReplicas) + + g.Config.Codesphere.Plans = files.PlansConfig{ + HostingPlans: map[int]files.HostingPlan{ + 1: hostingPlan, + }, + WorkspacePlans: map[int]files.WorkspacePlan{ + 1: workspacePlan, + }, + } +} diff --git a/internal/installer/config_generator_collector_test.go b/internal/installer/config_generator_collector_test.go new file mode 100644 index 00000000..75fbe77a --- /dev/null +++ b/internal/installer/config_generator_collector_test.go @@ -0,0 +1,168 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/codesphere-cloud/oms/internal/installer" +) + +var _ = Describe("ConfigGeneratorCollector", func() { + var ( + manager installer.InstallConfigManager + ) + + BeforeEach(func() { + manager = installer.NewInstallConfigManager() + }) + + Describe("CollectInteractively", func() { + It("should collect configuration after applying profile", func() { + err := manager.ApplyProfile("dev") + Expect(err).ToNot(HaveOccurred()) + + err = manager.CollectInteractively() + Expect(err).ToNot(HaveOccurred()) + + config := manager.GetInstallConfig() + Expect(config).ToNot(BeNil()) + Expect(config.Datacenter.Name).ToNot(BeEmpty()) + }) + }) + + Describe("Prompter", func() { + var prompter *installer.Prompter + + Context("Non-interactive mode", func() { + BeforeEach(func() { + prompter = installer.NewPrompter(false) + }) + + It("should return default string value", func() { + result := prompter.String("Test", "default") + Expect(result).To(Equal("default")) + }) + + It("should return default int value", func() { + result := prompter.Int("Test", 42) + Expect(result).To(Equal(42)) + }) + + It("should return default bool value", func() { + result := prompter.Bool("Test", true) + Expect(result).To(BeTrue()) + + result = prompter.Bool("Test", false) + Expect(result).To(BeFalse()) + }) + + It("should return default string slice value", func() { + defaultVal := []string{"a", "b", "c"} + result := prompter.StringSlice("Test", defaultVal) + Expect(result).To(Equal(defaultVal)) + }) + + It("should return default choice value", func() { + result := prompter.Choice("Test", []string{"opt1", "opt2", "opt3"}, "opt2") + Expect(result).To(Equal("opt2")) + }) + + It("should handle empty default values", func() { + result := prompter.String("Test", "") + Expect(result).To(Equal("")) + }) + + It("should handle zero default values", func() { + result := prompter.Int("Test", 0) + Expect(result).To(Equal(0)) + }) + + It("should handle empty slice defaults", func() { + result := prompter.StringSlice("Test", []string{}) + Expect(result).To(Equal([]string{})) + }) + }) + }) + + Describe("Configuration Fields After Collection", func() { + It("should have common configuration properties", func() { + err := manager.ApplyProfile("prod") + Expect(err).ToNot(HaveOccurred()) + err = manager.CollectInteractively() + Expect(err).ToNot(HaveOccurred()) + + config := manager.GetInstallConfig() + + // Datacenter + Expect(config.Datacenter.ID).To(Equal(1)) + Expect(config.Datacenter.City).To(Equal("Karlsruhe")) + Expect(config.Datacenter.CountryCode).To(Equal("DE")) + + // PostgreSQL + Expect(config.Postgres.Mode).To(Equal("install")) + Expect(config.Postgres.Primary).ToNot(BeNil()) + + // Kubernetes + Expect(config.Kubernetes.ManagedByCodesphere).To(BeTrue()) + Expect(config.Kubernetes.NeedsKubeConfig).To(BeFalse()) + + // Ceph + Expect(config.Ceph.Hosts[0].IsMaster).To(BeTrue()) + + // Codesphere + Expect(config.Codesphere.Plans.HostingPlans).To(HaveLen(1)) + Expect(config.Codesphere.Plans.WorkspacePlans).To(HaveLen(1)) + }) + + It("should have dev profile-specific values", func() { + err := manager.ApplyProfile("dev") + Expect(err).ToNot(HaveOccurred()) + err = manager.CollectInteractively() + Expect(err).ToNot(HaveOccurred()) + + config := manager.GetInstallConfig() + + Expect(config.Datacenter.Name).To(Equal("dev")) + Expect(config.Postgres.Primary.IP).To(Equal("127.0.0.1")) + Expect(config.Postgres.Primary.Hostname).To(Equal("localhost")) + Expect(config.Ceph.Hosts).To(HaveLen(1)) + Expect(config.Kubernetes.Workers).To(HaveLen(1)) + Expect(config.Codesphere.Domain).To(Equal("codesphere.local")) + }) + + It("should have prod profile-specific values", func() { + err := manager.ApplyProfile("prod") + Expect(err).ToNot(HaveOccurred()) + err = manager.CollectInteractively() + Expect(err).ToNot(HaveOccurred()) + + config := manager.GetInstallConfig() + + Expect(config.Datacenter.Name).To(Equal("production")) + Expect(config.Postgres.Primary.IP).To(Equal("10.50.0.2")) + Expect(config.Postgres.Primary.Hostname).To(Equal("pg-primary")) + Expect(config.Postgres.Replica).ToNot(BeNil()) + Expect(config.Postgres.Replica.IP).To(Equal("10.50.0.3")) + Expect(config.Ceph.Hosts).To(HaveLen(3)) + Expect(config.Kubernetes.Workers).To(HaveLen(3)) + Expect(config.Codesphere.Domain).To(Equal("codesphere.yourcompany.com")) + }) + + It("should have minimal profile-specific values", func() { + err := manager.ApplyProfile("minimal") + Expect(err).ToNot(HaveOccurred()) + err = manager.CollectInteractively() + Expect(err).ToNot(HaveOccurred()) + + config := manager.GetInstallConfig() + + Expect(config.Datacenter.Name).To(Equal("minimal")) + Expect(config.Postgres.Primary.IP).To(Equal("127.0.0.1")) + Expect(config.Kubernetes.Workers).To(BeEmpty()) + Expect(config.Codesphere.Plans.WorkspacePlans[1].MaxReplicas).To(Equal(1)) + }) + }) +}) diff --git a/internal/installer/config_manager.go b/internal/installer/config_manager.go new file mode 100644 index 00000000..8ad0dcb7 --- /dev/null +++ b/internal/installer/config_manager.go @@ -0,0 +1,261 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "fmt" + "io" + "net" + + "github.com/codesphere-cloud/oms/internal/installer/files" + "github.com/codesphere-cloud/oms/internal/util" +) + +func IsValidIP(ip string) bool { + return net.ParseIP(ip) != nil +} + +type InstallConfigManager interface { + // Profile management + ApplyProfile(profile string) error + // Configuration management + LoadInstallConfigFromFile(configPath string) error + LoadVaultFromFile(vaultPath string) error + ValidateInstallConfig() []string + ValidateVault() []string + GetInstallConfig() *files.RootConfig + CollectInteractively() error + // Output + GenerateSecrets() error + WriteInstallConfig(configPath string, withComments bool) error + WriteVault(vaultPath string, withComments bool) error +} + +type InstallConfig struct { + fileIO util.FileIO + Config *files.RootConfig + Vault *files.InstallVault +} + +func NewInstallConfigManager() InstallConfigManager { + return &InstallConfig{ + fileIO: &util.FilesystemWriter{}, + Config: &files.RootConfig{}, + Vault: &files.InstallVault{}, + } +} + +func (g *InstallConfig) LoadInstallConfigFromFile(configPath string) error { + file, err := g.fileIO.Open(configPath) + if err != nil { + return err + } + defer util.CloseFileIgnoreError(file) + + data, err := io.ReadAll(file) + if err != nil { + return fmt.Errorf("failed to read %s: %w", configPath, err) + } + + config := &files.RootConfig{} + if err := config.Unmarshal(data); err != nil { + return fmt.Errorf("failed to unmarshal %s: %w", configPath, err) + } + + g.Config = config + return nil +} + +func (g *InstallConfig) LoadVaultFromFile(vaultPath string) error { + vaultFile, err := g.fileIO.Open(vaultPath) + if err != nil { + return fmt.Errorf("error opening vault file: %v", err) + } + defer util.CloseFileIgnoreError(vaultFile) + + vaultData, err := io.ReadAll(vaultFile) + if err != nil { + return fmt.Errorf("failed to read vault.yaml: %v", err) + } + + vault := &files.InstallVault{} + if err := vault.Unmarshal(vaultData); err != nil { + return fmt.Errorf("failed to parse vault.yaml: %v", err) + } + + g.Vault = vault + return nil +} + +func (g *InstallConfig) ValidateInstallConfig() []string { + if g.Config == nil { + return []string{"config not set, cannot validate"} + } + + errors := []string{} + + if g.Config.Datacenter.ID == 0 { + errors = append(errors, "datacenter ID is required") + } + if g.Config.Datacenter.Name == "" { + errors = append(errors, "datacenter name is required") + } + + if g.Config.Postgres.Mode == "" { + errors = append(errors, "postgres mode is required (install or external)") + } else if g.Config.Postgres.Mode != "install" && g.Config.Postgres.Mode != "external" { + errors = append(errors, fmt.Sprintf("invalid postgres mode: %s (must be 'install' or 'external')", g.Config.Postgres.Mode)) + } + + switch g.Config.Postgres.Mode { + case "install": + if g.Config.Postgres.Primary == nil { + errors = append(errors, "postgres primary configuration is required when mode is 'install'") + } else { + if g.Config.Postgres.Primary.IP == "" { + errors = append(errors, "postgres primary IP is required") + } + if g.Config.Postgres.Primary.Hostname == "" { + errors = append(errors, "postgres primary hostname is required") + } + } + case "external": + if g.Config.Postgres.ServerAddress == "" { + errors = append(errors, "postgres server address is required when mode is 'external'") + } + } + + if len(g.Config.Ceph.Hosts) == 0 { + errors = append(errors, "at least one Ceph host is required") + } + for _, host := range g.Config.Ceph.Hosts { + if !IsValidIP(host.IPAddress) { + errors = append(errors, fmt.Sprintf("invalid Ceph host IP: %s", host.IPAddress)) + } + } + + if g.Config.Kubernetes.ManagedByCodesphere { + if len(g.Config.Kubernetes.ControlPlanes) == 0 { + errors = append(errors, "at least one K8s control plane node is required") + } + } else { + if g.Config.Kubernetes.PodCIDR == "" { + errors = append(errors, "pod CIDR is required for external Kubernetes") + } + if g.Config.Kubernetes.ServiceCIDR == "" { + errors = append(errors, "service CIDR is required for external Kubernetes") + } + } + + if g.Config.Codesphere.Domain == "" { + errors = append(errors, "Codesphere domain is required") + } + + return errors +} + +func (g *InstallConfig) ValidateVault() []string { + if g.Vault == nil { + return []string{"vault not set, cannot validate"} + } + + errors := []string{} + requiredSecrets := []string{"cephSshPrivateKey", "selfSignedCaKeyPem", "domainAuthPrivateKey", "domainAuthPublicKey"} + foundSecrets := make(map[string]bool) + + for _, secret := range g.Vault.Secrets { + foundSecrets[secret.Name] = true + } + + for _, required := range requiredSecrets { + if !foundSecrets[required] { + errors = append(errors, fmt.Sprintf("required secret missing: %s", required)) + } + } + + return errors +} + +func (g *InstallConfig) GetInstallConfig() *files.RootConfig { + return g.Config +} + +func (g *InstallConfig) WriteInstallConfig(configPath string, withComments bool) error { + if g.Config == nil { + return fmt.Errorf("no configuration provided - config is nil") + } + + configYAML, err := g.Config.Marshal() + if err != nil { + return fmt.Errorf("failed to marshal config.yaml: %w", err) + } + + if withComments { + configYAML = AddConfigComments(configYAML) + } + + if err := g.fileIO.CreateAndWrite(configPath, configYAML, "Configuration"); err != nil { + return err + } + + return nil +} + +func (g *InstallConfig) WriteVault(vaultPath string, withComments bool) error { + if g.Config == nil { + return fmt.Errorf("no configuration provided - config is nil") + } + + vault := g.Config.ExtractVault() + vaultYAML, err := vault.Marshal() + if err != nil { + return fmt.Errorf("failed to marshal vault.yaml: %w", err) + } + + if withComments { + vaultYAML = AddVaultComments(vaultYAML) + } + + if err := g.fileIO.CreateAndWrite(vaultPath, vaultYAML, "Secrets"); err != nil { + return err + } + + return nil +} + +func AddConfigComments(yamlData []byte) []byte { + header := `# Codesphere Installer Configuration +# Generated by OMS CLI +# +# This file contains the main configuration for installing Codesphere Private Cloud. +# Review and modify as needed before running the installer. +# +# For more information, see the installation documentation. + +` + return append([]byte(header), yamlData...) +} + +func AddVaultComments(yamlData []byte) []byte { + header := `# Codesphere Installer Secrets +# Generated by OMS CLI +# +# IMPORTANT: This file contains sensitive information! +# +# Before storing or transmitting this file: +# 1. Install SOPS and Age: brew install sops age +# 2. Generate an Age keypair: age-keygen -o age_key.txt +# 3. Encrypt this file: +# age-keygen -y age_key.txt # Get public key +# sops --encrypt --age --in-place prod.vault.yaml +# +# Keep the Age private key (age_key.txt) extremely secure! +# +# To edit the encrypted file later: +# export SOPS_AGE_KEY_FILE=/path/to/age_key.txt +# sops prod.vault.yaml + +` + return append([]byte(header), yamlData...) +} diff --git a/internal/installer/config_manager_profile.go b/internal/installer/config_manager_profile.go new file mode 100644 index 00000000..4d0b55e2 --- /dev/null +++ b/internal/installer/config_manager_profile.go @@ -0,0 +1,188 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "fmt" + + "github.com/codesphere-cloud/oms/internal/installer/files" +) + +const ( + PROFILE_DEV = "dev" + PROFILE_DEVELOPMENT = "development" + PROFILE_PROD = "prod" + PROFILE_PRODUCTION = "production" + PROFILE_MINIMAL = "minimal" +) + +func (g *InstallConfig) ApplyProfile(profile string) error { + if g.Config == nil { + g.Config = &files.RootConfig{} + } + + g.Config.Ceph.OSDs = []files.CephOSD{ + { + SpecID: "default", + Placement: files.CephPlacement{ + HostPattern: "*", + }, + DataDevices: files.CephDataDevices{ + Size: "240G:300G", + Limit: 1, + }, + DBDevices: files.CephDBDevices{ + Size: "120G:150G", + Limit: 1, + }, + }, + } + + g.Config.Datacenter.ID = 1 + g.Config.Datacenter.City = "Karlsruhe" + g.Config.Datacenter.CountryCode = "DE" + g.Config.Postgres.Mode = "install" + g.Config.Postgres.Primary = &files.PostgresPrimaryConfig{} + g.Config.Postgres.Replica = &files.PostgresReplicaConfig{} + g.Config.Kubernetes.ManagedByCodesphere = true + g.Config.Kubernetes.NeedsKubeConfig = false + g.Config.Cluster.Certificates = files.ClusterCertificates{ + CA: files.CAConfig{ + Algorithm: "RSA", + KeySizeBits: 2048, + }, + } + g.Config.Cluster.Gateway = files.GatewayConfig{ServiceType: "LoadBalancer"} + g.Config.Cluster.PublicGateway = files.GatewayConfig{ServiceType: "LoadBalancer"} + g.Config.MetalLB = &files.MetalLBConfig{ + Enabled: false, + Pools: []files.MetalLBPoolDef{}, + } + g.Config.Codesphere.Experiments = []string{} + g.Config.Codesphere.WorkspaceImages = &files.WorkspaceImagesConfig{ + Agent: &files.ImageRef{ + BomRef: "workspace-agent-24.04", + }, + } + g.Config.Codesphere.DeployConfig = files.DeployConfig{ + Images: map[string]files.ImageConfig{ + "ubuntu-24.04": { + Name: "Ubuntu 24.04", + SupportedUntil: "2028-05-31", + Flavors: map[string]files.FlavorConfig{ + "default": { + Image: files.ImageRef{ + BomRef: "workspace-agent-24.04", + }, + Pool: map[int]int{1: 1}, + }, + }, + }, + }, + } + g.Config.Codesphere.Plans = files.PlansConfig{ + HostingPlans: map[int]files.HostingPlan{ + 1: { + CPUTenth: 10, + GPUParts: 0, + MemoryMb: 2048, + StorageMb: 20480, + TempStorageMb: 1024, + }, + }, + WorkspacePlans: map[int]files.WorkspacePlan{ + 1: { + Name: "Standard", + HostingPlanID: 1, + MaxReplicas: 3, + OnDemand: true, + }, + }, + } + g.Config.ManagedServiceBackends = &files.ManagedServiceBackendsConfig{ + Postgres: make(map[string]interface{}), + } + g.Config.Secrets.BaseDir = "/root/secrets" + + switch profile { + case PROFILE_DEV, PROFILE_DEVELOPMENT: + g.Config.Datacenter.Name = "dev" + g.Config.Postgres.Primary.IP = "127.0.0.1" + g.Config.Postgres.Primary.Hostname = "localhost" + g.Config.Ceph.NodesSubnet = "127.0.0.1/32" + g.Config.Ceph.Hosts = []files.CephHost{{Hostname: "localhost", IPAddress: "127.0.0.1", IsMaster: true}} + g.Config.Kubernetes.APIServerHost = "127.0.0.1" + g.Config.Kubernetes.ControlPlanes = []files.K8sNode{{IPAddress: "127.0.0.1"}} + g.Config.Kubernetes.Workers = []files.K8sNode{{IPAddress: "127.0.0.1"}} + g.Config.Codesphere.Domain = "codesphere.local" + g.Config.Codesphere.WorkspaceHostingBaseDomain = "ws.local" + g.Config.Codesphere.CustomDomains.CNameBaseDomain = "custom.local" + g.Config.Codesphere.DNSServers = []string{"8.8.8.8", "1.1.1.1"} + fmt.Println("Applied 'dev' profile: single-node development setup") + + case PROFILE_PROD, PROFILE_PRODUCTION: + g.Config.Datacenter.Name = "production" + g.Config.Postgres.Primary.IP = "10.50.0.2" + g.Config.Postgres.Primary.Hostname = "pg-primary" + g.Config.Postgres.Replica.IP = "10.50.0.3" + g.Config.Postgres.Replica.Name = "replica1" + g.Config.Ceph.NodesSubnet = "10.53.101.0/24" + g.Config.Ceph.Hosts = []files.CephHost{ + {Hostname: "ceph-node-0", IPAddress: "10.53.101.2", IsMaster: true}, + {Hostname: "ceph-node-1", IPAddress: "10.53.101.3", IsMaster: false}, + {Hostname: "ceph-node-2", IPAddress: "10.53.101.4", IsMaster: false}, + } + g.Config.Kubernetes.ManagedByCodesphere = true + g.Config.Kubernetes.APIServerHost = "10.50.0.2" + g.Config.Kubernetes.ControlPlanes = []files.K8sNode{ + {IPAddress: "10.50.0.2"}, + } + g.Config.Kubernetes.Workers = []files.K8sNode{ + {IPAddress: "10.50.0.2"}, + {IPAddress: "10.50.0.3"}, + {IPAddress: "10.50.0.4"}, + } + g.Config.Codesphere.Domain = "codesphere.yourcompany.com" + g.Config.Codesphere.WorkspaceHostingBaseDomain = "ws.yourcompany.com" + g.Config.Codesphere.CustomDomains.CNameBaseDomain = "custom.yourcompany.com" + g.Config.Codesphere.DNSServers = []string{"1.1.1.1", "8.8.8.8"} + g.Config.Codesphere.Plans.WorkspacePlans = map[int]files.WorkspacePlan{ + 1: { + Name: "Standard Developer", + HostingPlanID: 1, + MaxReplicas: 3, + OnDemand: true, + }, + } + fmt.Println("Applied 'production' profile: HA multi-node setup") + + case PROFILE_MINIMAL: + g.Config.Datacenter.Name = "minimal" + g.Config.Postgres.Primary.IP = "127.0.0.1" + g.Config.Postgres.Primary.Hostname = "localhost" + g.Config.Ceph.NodesSubnet = "127.0.0.1/32" + g.Config.Ceph.Hosts = []files.CephHost{{Hostname: "localhost", IPAddress: "127.0.0.1", IsMaster: true}} + g.Config.Kubernetes.APIServerHost = "127.0.0.1" + g.Config.Kubernetes.ControlPlanes = []files.K8sNode{{IPAddress: "127.0.0.1"}} + g.Config.Kubernetes.Workers = []files.K8sNode{} + g.Config.Codesphere.Domain = "codesphere.local" + g.Config.Codesphere.WorkspaceHostingBaseDomain = "ws.local" + g.Config.Codesphere.CustomDomains.CNameBaseDomain = "custom.local" + g.Config.Codesphere.DNSServers = []string{"8.8.8.8"} + g.Config.Codesphere.Plans.WorkspacePlans = map[int]files.WorkspacePlan{ + 1: { + Name: "Standard Developer", + HostingPlanID: 1, + MaxReplicas: 1, + OnDemand: true, + }, + } + fmt.Println("Applied 'minimal' profile: minimal single-node setup") + + default: + return fmt.Errorf("unknown profile: %s, available profiles: dev, prod, minimal", profile) + } + + return nil +} diff --git a/internal/installer/config_manager_profile_test.go b/internal/installer/config_manager_profile_test.go new file mode 100644 index 00000000..44243b50 --- /dev/null +++ b/internal/installer/config_manager_profile_test.go @@ -0,0 +1,303 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/codesphere-cloud/oms/internal/installer" +) + +var _ = Describe("ConfigManagerProfile", func() { + var ( + manager installer.InstallConfigManager + ) + + BeforeEach(func() { + manager = installer.NewInstallConfigManager() + }) + + Describe("ApplyProfile", func() { + Context("with dev profile", func() { + It("should configure single-node development setup", func() { + err := manager.ApplyProfile(installer.PROFILE_DEV) + Expect(err).ToNot(HaveOccurred()) + + config := manager.GetInstallConfig() + Expect(config.Datacenter.Name).To(Equal("dev")) + Expect(config.Postgres.Primary.IP).To(Equal("127.0.0.1")) + Expect(config.Postgres.Primary.Hostname).To(Equal("localhost")) + Expect(config.Ceph.NodesSubnet).To(Equal("127.0.0.1/32")) + Expect(config.Ceph.Hosts).To(HaveLen(1)) + Expect(config.Ceph.Hosts[0].IPAddress).To(Equal("127.0.0.1")) + Expect(config.Ceph.Hosts[0].IsMaster).To(BeTrue()) + Expect(config.Kubernetes.APIServerHost).To(Equal("127.0.0.1")) + Expect(config.Kubernetes.ControlPlanes).To(HaveLen(1)) + Expect(config.Kubernetes.Workers).To(HaveLen(1)) + Expect(config.Codesphere.Domain).To(Equal("codesphere.local")) + }) + + It("should set managed Kubernetes configuration", func() { + err := manager.ApplyProfile(installer.PROFILE_DEV) + Expect(err).ToNot(HaveOccurred()) + + config := manager.GetInstallConfig() + Expect(config.Kubernetes.ManagedByCodesphere).To(BeTrue()) + Expect(config.Kubernetes.NeedsKubeConfig).To(BeFalse()) + }) + }) + + Context("with development profile", func() { + It("should apply development profile as alias for dev", func() { + err := manager.ApplyProfile(installer.PROFILE_DEVELOPMENT) + Expect(err).ToNot(HaveOccurred()) + + config := manager.GetInstallConfig() + Expect(config.Datacenter.Name).To(Equal("dev")) + }) + }) + + Context("with prod profile", func() { + It("should configure HA multi-node setup", func() { + err := manager.ApplyProfile(installer.PROFILE_PROD) + Expect(err).ToNot(HaveOccurred()) + + config := manager.GetInstallConfig() + Expect(config.Datacenter.Name).To(Equal("production")) + Expect(config.Postgres.Primary.IP).To(Equal("10.50.0.2")) + Expect(config.Postgres.Primary.Hostname).To(Equal("pg-primary")) + Expect(config.Postgres.Replica).ToNot(BeNil()) + Expect(config.Postgres.Replica.IP).To(Equal("10.50.0.3")) + }) + + It("should configure multiple Ceph nodes", func() { + err := manager.ApplyProfile(installer.PROFILE_PROD) + Expect(err).ToNot(HaveOccurred()) + + config := manager.GetInstallConfig() + Expect(config.Ceph.Hosts).To(HaveLen(3)) + Expect(config.Ceph.Hosts[0].IsMaster).To(BeTrue()) + Expect(config.Ceph.Hosts[1].IsMaster).To(BeFalse()) + Expect(config.Ceph.Hosts[2].IsMaster).To(BeFalse()) + }) + + It("should configure multiple Kubernetes nodes", func() { + err := manager.ApplyProfile(installer.PROFILE_PROD) + Expect(err).ToNot(HaveOccurred()) + + config := manager.GetInstallConfig() + Expect(config.Kubernetes.ControlPlanes).To(HaveLen(1)) + Expect(config.Kubernetes.Workers).To(HaveLen(3)) + }) + + It("should use production domain names", func() { + err := manager.ApplyProfile(installer.PROFILE_PROD) + Expect(err).ToNot(HaveOccurred()) + + config := manager.GetInstallConfig() + Expect(config.Codesphere.Domain).To(Equal("codesphere.yourcompany.com")) + Expect(config.Codesphere.WorkspaceHostingBaseDomain).To(Equal("ws.yourcompany.com")) + }) + }) + + Context("with production profile", func() { + It("should apply production profile as alias for prod", func() { + err := manager.ApplyProfile(installer.PROFILE_PRODUCTION) + Expect(err).ToNot(HaveOccurred()) + + config := manager.GetInstallConfig() + Expect(config.Datacenter.Name).To(Equal("production")) + }) + }) + + Context("with minimal profile", func() { + It("should configure minimal single-node setup", func() { + err := manager.ApplyProfile(installer.PROFILE_MINIMAL) + Expect(err).ToNot(HaveOccurred()) + + config := manager.GetInstallConfig() + Expect(config.Datacenter.Name).To(Equal("minimal")) + Expect(config.Postgres.Primary.IP).To(Equal("127.0.0.1")) + Expect(config.Kubernetes.Workers).To(BeEmpty()) + }) + + It("should configure minimal workspace plan", func() { + err := manager.ApplyProfile(installer.PROFILE_MINIMAL) + Expect(err).ToNot(HaveOccurred()) + + config := manager.GetInstallConfig() + plan := config.Codesphere.Plans.WorkspacePlans[1] + Expect(plan.MaxReplicas).To(Equal(1)) + }) + }) + + Context("with unknown profile", func() { + It("should return error for unknown profile", func() { + err := manager.ApplyProfile("unknown") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("unknown profile")) + }) + }) + + Context("common configuration across all profiles", func() { + profiles := []string{ + installer.PROFILE_DEV, + installer.PROFILE_PROD, + installer.PROFILE_MINIMAL, + } + + for _, profile := range profiles { + It("should set all common properties for "+profile, func() { + err := manager.ApplyProfile(profile) + Expect(err).ToNot(HaveOccurred()) + + config := manager.GetInstallConfig() + + // Datacenter config + Expect(config.Datacenter.ID).To(Equal(1)) + Expect(config.Datacenter.City).To(Equal("Karlsruhe")) + Expect(config.Datacenter.CountryCode).To(Equal("DE")) + + // PostgreSQL mode + Expect(config.Postgres.Mode).To(Equal("install")) + Expect(config.Postgres.Primary).ToNot(BeNil()) + + // Kubernetes + Expect(config.Kubernetes.ManagedByCodesphere).To(BeTrue()) + Expect(config.Kubernetes.NeedsKubeConfig).To(BeFalse()) + + // Cluster certificates + Expect(config.Cluster.Certificates.CA.Algorithm).To(Equal("RSA")) + Expect(config.Cluster.Certificates.CA.KeySizeBits).To(Equal(2048)) + + // Gateway + Expect(config.Cluster.Gateway.ServiceType).To(Equal("LoadBalancer")) + Expect(config.Cluster.PublicGateway.ServiceType).To(Equal("LoadBalancer")) + + // MetalLB + Expect(config.MetalLB).ToNot(BeNil()) + Expect(config.MetalLB.Enabled).To(BeFalse()) + + // Ceph OSDs + Expect(config.Ceph.OSDs).To(HaveLen(1)) + osd := config.Ceph.OSDs[0] + Expect(osd.SpecID).To(Equal("default")) + Expect(osd.Placement.HostPattern).To(Equal("*")) + Expect(osd.DataDevices.Size).To(Equal("240G:300G")) + Expect(osd.DataDevices.Limit).To(Equal(1)) + Expect(osd.DBDevices.Size).To(Equal("120G:150G")) + Expect(osd.DBDevices.Limit).To(Equal(1)) + + // Workspace images + Expect(config.Codesphere.WorkspaceImages).ToNot(BeNil()) + Expect(config.Codesphere.WorkspaceImages.Agent).ToNot(BeNil()) + Expect(config.Codesphere.WorkspaceImages.Agent.BomRef).To(Equal("workspace-agent-24.04")) + + // Deploy config + images := config.Codesphere.DeployConfig.Images + Expect(images).To(HaveKey("ubuntu-24.04")) + ubuntu := images["ubuntu-24.04"] + Expect(ubuntu.Name).To(Equal("Ubuntu 24.04")) + Expect(ubuntu.SupportedUntil).To(Equal("2028-05-31")) + Expect(ubuntu.Flavors).To(HaveKey("default")) + + // Hosting plans + hostingPlans := config.Codesphere.Plans.HostingPlans + Expect(hostingPlans).To(HaveKey(1)) + hostingPlan := hostingPlans[1] + Expect(hostingPlan.CPUTenth).To(Equal(10)) + Expect(hostingPlan.MemoryMb).To(Equal(2048)) + Expect(hostingPlan.StorageMb).To(Equal(20480)) + Expect(hostingPlan.TempStorageMb).To(Equal(1024)) + + // Workspace plans + workspacePlans := config.Codesphere.Plans.WorkspacePlans + Expect(workspacePlans).To(HaveKey(1)) + workspacePlan := workspacePlans[1] + Expect(workspacePlan.HostingPlanID).To(Equal(1)) + Expect(workspacePlan.OnDemand).To(BeTrue()) + + // Managed service backends + Expect(config.ManagedServiceBackends).ToNot(BeNil()) + Expect(config.ManagedServiceBackends.Postgres).ToNot(BeNil()) + + // Secrets + Expect(config.Secrets.BaseDir).To(Equal("/root/secrets")) + }) + } + }) + + Context("profile-specific differences", func() { + It("should have different datacenter names", func() { + devManager := installer.NewInstallConfigManager() + prodManager := installer.NewInstallConfigManager() + minimalManager := installer.NewInstallConfigManager() + + err := devManager.ApplyProfile(installer.PROFILE_DEV) + Expect(err).ToNot(HaveOccurred()) + err = prodManager.ApplyProfile(installer.PROFILE_PROD) + Expect(err).ToNot(HaveOccurred()) + err = minimalManager.ApplyProfile(installer.PROFILE_MINIMAL) + Expect(err).ToNot(HaveOccurred()) + + Expect(devManager.GetInstallConfig().Datacenter.Name).To(Equal("dev")) + Expect(prodManager.GetInstallConfig().Datacenter.Name).To(Equal("production")) + Expect(minimalManager.GetInstallConfig().Datacenter.Name).To(Equal("minimal")) + }) + + It("should have different PostgreSQL replica configurations", func() { + devManager := installer.NewInstallConfigManager() + prodManager := installer.NewInstallConfigManager() + + err := devManager.ApplyProfile(installer.PROFILE_DEV) + Expect(err).ToNot(HaveOccurred()) + err = prodManager.ApplyProfile(installer.PROFILE_PROD) + Expect(err).ToNot(HaveOccurred()) + + Expect(prodManager.GetInstallConfig().Postgres.Replica.IP).To(Equal("10.50.0.3")) + }) + + It("should have different number of Ceph hosts", func() { + devManager := installer.NewInstallConfigManager() + prodManager := installer.NewInstallConfigManager() + + err := devManager.ApplyProfile(installer.PROFILE_DEV) + Expect(err).ToNot(HaveOccurred()) + err = prodManager.ApplyProfile(installer.PROFILE_PROD) + Expect(err).ToNot(HaveOccurred()) + + Expect(devManager.GetInstallConfig().Ceph.Hosts).To(HaveLen(1)) + Expect(prodManager.GetInstallConfig().Ceph.Hosts).To(HaveLen(3)) + }) + + It("should have different worker node counts", func() { + devManager := installer.NewInstallConfigManager() + prodManager := installer.NewInstallConfigManager() + minimalManager := installer.NewInstallConfigManager() + + err := devManager.ApplyProfile(installer.PROFILE_DEV) + Expect(err).ToNot(HaveOccurred()) + err = prodManager.ApplyProfile(installer.PROFILE_PROD) + Expect(err).ToNot(HaveOccurred()) + err = minimalManager.ApplyProfile(installer.PROFILE_MINIMAL) + Expect(err).ToNot(HaveOccurred()) + + Expect(devManager.GetInstallConfig().Kubernetes.Workers).To(HaveLen(1)) + Expect(prodManager.GetInstallConfig().Kubernetes.Workers).To(HaveLen(3)) + Expect(minimalManager.GetInstallConfig().Kubernetes.Workers).To(BeEmpty()) + }) + }) + }) + + Describe("Profile Constants", func() { + It("should have correct profile constant values", func() { + Expect(installer.PROFILE_DEV).To(Equal("dev")) + Expect(installer.PROFILE_DEVELOPMENT).To(Equal("development")) + Expect(installer.PROFILE_PROD).To(Equal("prod")) + Expect(installer.PROFILE_PRODUCTION).To(Equal("production")) + Expect(installer.PROFILE_MINIMAL).To(Equal("minimal")) + }) + }) +}) diff --git a/internal/installer/config_manager_secrets.go b/internal/installer/config_manager_secrets.go new file mode 100644 index 00000000..dce42dd4 --- /dev/null +++ b/internal/installer/config_manager_secrets.go @@ -0,0 +1,81 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "fmt" + + "github.com/codesphere-cloud/oms/internal/installer/files" +) + +func (g *InstallConfig) GenerateSecrets() error { + fmt.Println("Generating domain authentication keys...") + var err error + g.Config.Codesphere.DomainAuthPublicKey, g.Config.Codesphere.DomainAuthPrivateKey, err = GenerateECDSAKeyPair() + if err != nil { + return fmt.Errorf("failed to generate domain auth keys: %w", err) + } + + fmt.Println("Generating ingress CA certificate...") + g.Config.Cluster.IngressCAKey, g.Config.Cluster.Certificates.CA.CertPem, err = GenerateCA("Cluster Ingress CA", "DE", "Karlsruhe", "Codesphere") + if err != nil { + return fmt.Errorf("failed to generate ingress CA: %w", err) + } + + fmt.Println("Generating Ceph SSH keys...") + g.Config.Ceph.CephAdmSSHKey.PublicKey, g.Config.Ceph.SshPrivateKey, err = GenerateSSHKeyPair() + if err != nil { + return fmt.Errorf("failed to generate Ceph SSH keys: %w", err) + } + + if g.Config.Postgres.Primary != nil { + if err := g.generatePostgresSecrets(g.Config); err != nil { + return err + } + } + + return nil +} + +func (g *InstallConfig) generatePostgresSecrets(config *files.RootConfig) error { + fmt.Println("Generating PostgreSQL certificates and passwords...") + var err error + config.Postgres.CaCertPrivateKey, config.Postgres.CACertPem, err = GenerateCA("PostgreSQL CA", "DE", "Karlsruhe", "Codesphere") + if err != nil { + return fmt.Errorf("failed to generate PostgreSQL CA: %w", err) + } + + config.Postgres.Primary.PrivateKey, config.Postgres.Primary.SSLConfig.ServerCertPem, err = GenerateServerCertificate( + config.Postgres.CaCertPrivateKey, + config.Postgres.CACertPem, + config.Postgres.Primary.Hostname, + []string{config.Postgres.Primary.IP}, + ) + if err != nil { + return fmt.Errorf("failed to generate primary PostgreSQL certificate: %w", err) + } + + config.Postgres.AdminPassword = GeneratePassword(32) + config.Postgres.ReplicaPassword = GeneratePassword(32) + + if config.Postgres.Replica != nil { + config.Postgres.Replica.PrivateKey, config.Postgres.Replica.SSLConfig.ServerCertPem, err = GenerateServerCertificate( + config.Postgres.CaCertPrivateKey, + config.Postgres.CACertPem, + config.Postgres.Replica.Name, + []string{config.Postgres.Replica.IP}, + ) + if err != nil { + return fmt.Errorf("failed to generate replica PostgreSQL certificate: %w", err) + } + } + + services := []string{"auth", "deployment", "ide", "marketplace", "payment", "public_api", "team", "workspace"} + config.Postgres.UserPasswords = make(map[string]string) + for _, service := range services { + config.Postgres.UserPasswords[service] = GeneratePassword(32) + } + + return nil +} diff --git a/internal/installer/config_manager_secrets_test.go b/internal/installer/config_manager_secrets_test.go new file mode 100644 index 00000000..c57c7455 --- /dev/null +++ b/internal/installer/config_manager_secrets_test.go @@ -0,0 +1,207 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer_test + +import ( + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/codesphere-cloud/oms/internal/installer" + "github.com/codesphere-cloud/oms/internal/installer/files" +) + +var _ = Describe("ConfigManagerSecrets", func() { + var ( + configManager *installer.InstallConfig + ) + + BeforeEach(func() { + configManager = &installer.InstallConfig{ + Config: &files.RootConfig{}, + } + }) + + Describe("GenerateSecrets", func() { + Context("with basic configuration", func() { + BeforeEach(func() { + configManager.Config = &files.RootConfig{ + Postgres: files.PostgresConfig{ + Primary: &files.PostgresPrimaryConfig{ + IP: "10.50.0.2", + Hostname: "pg-primary", + }, + }, + } + }) + + It("should generate secrets without error", func() { + err := configManager.GenerateSecrets() + Expect(err).ToNot(HaveOccurred()) + }) + + It("should generate domain auth ECDSA key pair", func() { + err := configManager.GenerateSecrets() + Expect(err).ToNot(HaveOccurred()) + + Expect(configManager.Config.Codesphere.DomainAuthPublicKey).ToNot(BeEmpty()) + Expect(configManager.Config.Codesphere.DomainAuthPrivateKey).ToNot(BeEmpty()) + Expect(configManager.Config.Codesphere.DomainAuthPublicKey).To(ContainSubstring("BEGIN")) + Expect(configManager.Config.Codesphere.DomainAuthPrivateKey).To(ContainSubstring("BEGIN")) + }) + + It("should generate Ceph SSH keys", func() { + err := configManager.GenerateSecrets() + Expect(err).ToNot(HaveOccurred()) + + Expect(configManager.Config.Ceph.CephAdmSSHKey.PublicKey).ToNot(BeEmpty()) + Expect(configManager.Config.Ceph.SshPrivateKey).ToNot(BeEmpty()) + }) + + It("should generate PostgreSQL secrets", func() { + err := configManager.GenerateSecrets() + Expect(err).ToNot(HaveOccurred()) + + Expect(configManager.Config.Postgres.CaCertPrivateKey).ToNot(BeEmpty()) + Expect(configManager.Config.Postgres.CACertPem).ToNot(BeEmpty()) + Expect(configManager.Config.Postgres.AdminPassword).ToNot(BeEmpty()) + Expect(configManager.Config.Postgres.ReplicaPassword).ToNot(BeEmpty()) + }) + + It("should generate unique passwords", func() { + err := configManager.GenerateSecrets() + Expect(err).ToNot(HaveOccurred()) + + adminPwd := configManager.Config.Postgres.AdminPassword + replicaPwd := configManager.Config.Postgres.ReplicaPassword + + Expect(adminPwd).ToNot(Equal(replicaPwd)) + Expect(adminPwd).To(HaveLen(32)) + Expect(replicaPwd).To(HaveLen(32)) + }) + }) + + Context("with PostgreSQL replica configuration", func() { + BeforeEach(func() { + configManager.Config = &files.RootConfig{ + Postgres: files.PostgresConfig{ + Primary: &files.PostgresPrimaryConfig{ + IP: "10.50.0.2", + Hostname: "pg-primary", + }, + Replica: &files.PostgresReplicaConfig{ + IP: "10.50.0.3", + Name: "replica1", + }, + }, + } + }) + + It("should generate valid certificates for primary and replica", func() { + err := configManager.GenerateSecrets() + Expect(err).ToNot(HaveOccurred()) + + // Primary server certificate + Expect(configManager.Config.Postgres.Primary.PrivateKey).ToNot(BeEmpty()) + Expect(configManager.Config.Postgres.Primary.SSLConfig.ServerCertPem).ToNot(BeEmpty()) + Expect(strings.HasPrefix(configManager.Config.Postgres.Primary.PrivateKey, "-----BEGIN")).To(BeTrue()) + Expect(strings.HasPrefix(configManager.Config.Postgres.Primary.SSLConfig.ServerCertPem, "-----BEGIN CERTIFICATE-----")).To(BeTrue()) + + // Replica server certificate + Expect(configManager.Config.Postgres.Replica.PrivateKey).ToNot(BeEmpty()) + Expect(configManager.Config.Postgres.Replica.SSLConfig.ServerCertPem).ToNot(BeEmpty()) + Expect(strings.HasPrefix(configManager.Config.Postgres.Replica.PrivateKey, "-----BEGIN")).To(BeTrue()) + Expect(strings.HasPrefix(configManager.Config.Postgres.Replica.SSLConfig.ServerCertPem, "-----BEGIN CERTIFICATE-----")).To(BeTrue()) + }) + }) + + Context("without PostgreSQL primary configuration", func() { + BeforeEach(func() { + configManager.Config = &files.RootConfig{ + Postgres: files.PostgresConfig{ + Primary: nil, + }, + } + }) + + It("should not generate PostgreSQL secrets", func() { + err := configManager.GenerateSecrets() + Expect(err).ToNot(HaveOccurred()) + + Expect(configManager.Config.Postgres.CACertPem).To(BeEmpty()) + Expect(configManager.Config.Postgres.AdminPassword).To(BeEmpty()) + }) + + It("should still generate other secrets", func() { + err := configManager.GenerateSecrets() + Expect(err).ToNot(HaveOccurred()) + + Expect(configManager.Config.Codesphere.DomainAuthPublicKey).ToNot(BeEmpty()) + Expect(configManager.Config.Cluster.IngressCAKey).ToNot(BeEmpty()) + Expect(configManager.Config.Ceph.SshPrivateKey).ToNot(BeEmpty()) + }) + }) + + Context("secret generation consistency", func() { + It("should generate different keys on each invocation", func() { + config1 := &installer.InstallConfig{ + Config: &files.RootConfig{ + Postgres: files.PostgresConfig{ + Primary: &files.PostgresPrimaryConfig{ + IP: "10.50.0.2", + Hostname: "pg-primary", + }, + }, + }, + } + config2 := &installer.InstallConfig{ + Config: &files.RootConfig{ + Postgres: files.PostgresConfig{ + Primary: &files.PostgresPrimaryConfig{ + IP: "10.50.0.2", + Hostname: "pg-primary", + }, + }, + }, + } + + err1 := config1.GenerateSecrets() + err2 := config2.GenerateSecrets() + + Expect(err1).ToNot(HaveOccurred()) + Expect(err2).ToNot(HaveOccurred()) + + Expect(config1.Config.Ceph.SshPrivateKey).ToNot(Equal(config2.Config.Ceph.SshPrivateKey)) + Expect(config1.Config.Postgres.AdminPassword).ToNot(Equal(config2.Config.Postgres.AdminPassword)) + }) + }) + + Context("cluster certificate validation", func() { + BeforeEach(func() { + configManager.Config = &files.RootConfig{ + Postgres: files.PostgresConfig{ + Primary: &files.PostgresPrimaryConfig{ + IP: "10.50.0.2", + Hostname: "pg-primary", + }, + }, + } + }) + + It("should generate valid ingress CA with proper PEM format", func() { + err := configManager.GenerateSecrets() + Expect(err).ToNot(HaveOccurred()) + + // Check CA key is PEM encoded + Expect(strings.HasPrefix(configManager.Config.Cluster.IngressCAKey, "-----BEGIN")).To(BeTrue()) + Expect(strings.HasSuffix(strings.TrimSpace(configManager.Config.Cluster.IngressCAKey), "-----")).To(BeTrue()) + + // Check CA cert is PEM encoded + Expect(strings.HasPrefix(configManager.Config.Cluster.Certificates.CA.CertPem, "-----BEGIN CERTIFICATE-----")).To(BeTrue()) + Expect(strings.HasSuffix(strings.TrimSpace(configManager.Config.Cluster.Certificates.CA.CertPem), "-----END CERTIFICATE-----")).To(BeTrue()) + }) + }) + }) +}) diff --git a/internal/installer/config_manager_test.go b/internal/installer/config_manager_test.go new file mode 100644 index 00000000..eeb21d46 --- /dev/null +++ b/internal/installer/config_manager_test.go @@ -0,0 +1,463 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer_test + +import ( + "bytes" + "os" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/codesphere-cloud/oms/internal/installer" + "github.com/codesphere-cloud/oms/internal/installer/files" +) + +type MockFileIO struct { + files map[string][]byte + createError error + openError error + writeError error + existsResult bool + isDirResult bool + mkdirAllError error +} + +func NewMockFileIO() *MockFileIO { + return &MockFileIO{ + files: make(map[string][]byte), + } +} + +func (m *MockFileIO) Create(filename string) (*os.File, error) { + if m.createError != nil { + return nil, m.createError + } + return nil, nil +} + +func (m *MockFileIO) CreateAndWrite(filePath string, data []byte, fileType string) error { + if m.writeError != nil { + return m.writeError + } + m.files[filePath] = data + return nil +} + +func (m *MockFileIO) Open(filename string) (*os.File, error) { + if m.openError != nil { + return nil, m.openError + } + return nil, nil +} + +func (m *MockFileIO) OpenAppend(filename string) (*os.File, error) { + return nil, nil +} + +func (m *MockFileIO) Exists(path string) bool { + _, exists := m.files[path] + return exists || m.existsResult +} + +func (m *MockFileIO) IsDirectory(path string) (bool, error) { + return m.isDirResult, nil +} + +func (m *MockFileIO) MkdirAll(path string, perm os.FileMode) error { + return m.mkdirAllError +} + +func (m *MockFileIO) OpenFile(name string, flag int, perm os.FileMode) (*os.File, error) { + return nil, nil +} + +func (m *MockFileIO) WriteFile(filename string, data []byte, perm os.FileMode) error { + if m.writeError != nil { + return m.writeError + } + m.files[filename] = data + return nil +} + +func (m *MockFileIO) ReadDir(dirname string) ([]os.DirEntry, error) { + return nil, nil +} + +type MockFile struct { + *bytes.Buffer + closed bool +} + +func (m *MockFile) Close() error { + m.closed = true + return nil +} + +var _ = Describe("ConfigManager", func() { + var ( + configManager *installer.InstallConfig + ) + + BeforeEach(func() { + configManager = &installer.InstallConfig{ + Config: &files.RootConfig{}, + } + }) + + Describe("NewInstallConfigManager", func() { + It("should create a new config manager", func() { + manager := installer.NewInstallConfigManager() + Expect(manager).ToNot(BeNil()) + }) + }) + + Describe("IsValidIP", func() { + It("should validate correct IPv4 addresses", func() { + Expect(installer.IsValidIP("192.168.1.1")).To(BeTrue()) + Expect(installer.IsValidIP("10.0.0.1")).To(BeTrue()) + Expect(installer.IsValidIP("127.0.0.1")).To(BeTrue()) + }) + + It("should validate correct IPv6 addresses", func() { + Expect(installer.IsValidIP("::1")).To(BeTrue()) + Expect(installer.IsValidIP("2001:db8::1")).To(BeTrue()) + }) + + It("should reject invalid IP addresses", func() { + Expect(installer.IsValidIP("256.1.1.1")).To(BeFalse()) + Expect(installer.IsValidIP("not-an-ip")).To(BeFalse()) + Expect(installer.IsValidIP("")).To(BeFalse()) + Expect(installer.IsValidIP("192.168.1")).To(BeFalse()) + }) + }) + + Describe("ValidateInstallConfig", func() { + Context("with nil config", func() { + It("should return error", func() { + configManager.Config = nil + errors := configManager.ValidateInstallConfig() + Expect(errors).To(HaveLen(1)) + Expect(errors[0]).To(ContainSubstring("config not set")) + }) + }) + + Context("with empty config", func() { + It("should return multiple validation errors", func() { + configManager.Config = &files.RootConfig{} + errors := configManager.ValidateInstallConfig() + Expect(errors).ToNot(BeEmpty()) + }) + }) + + Context("datacenter validation", func() { + It("should require datacenter ID", func() { + configManager.Config.Datacenter.ID = 0 + errors := configManager.ValidateInstallConfig() + Expect(errors).To(ContainElement(ContainSubstring("datacenter ID is required"))) + }) + + It("should require datacenter name", func() { + configManager.Config.Datacenter.Name = "" + errors := configManager.ValidateInstallConfig() + Expect(errors).To(ContainElement(ContainSubstring("datacenter name is required"))) + }) + }) + + Context("postgres validation", func() { + It("should require postgres mode", func() { + configManager.Config.Postgres.Mode = "" + errors := configManager.ValidateInstallConfig() + Expect(errors).To(ContainElement(ContainSubstring("postgres mode is required"))) + }) + + It("should reject invalid postgres mode", func() { + configManager.Config.Postgres.Mode = "invalid" + errors := configManager.ValidateInstallConfig() + Expect(errors).To(ContainElement(ContainSubstring("invalid postgres mode"))) + }) + + Context("install mode", func() { + BeforeEach(func() { + configManager.Config.Postgres.Mode = "install" + }) + + It("should require primary configuration", func() { + configManager.Config.Postgres.Primary = nil + errors := configManager.ValidateInstallConfig() + Expect(errors).To(ContainElement(ContainSubstring("postgres primary configuration is required"))) + }) + + It("should require primary IP", func() { + configManager.Config.Postgres.Mode = "install" + configManager.Config.Postgres.Primary = &files.PostgresPrimaryConfig{ + IP: "", + Hostname: "pg-primary", + } + errors := configManager.ValidateInstallConfig() + Expect(errors).To(ContainElement(ContainSubstring("postgres primary IP is required"))) + }) + + It("should require primary hostname", func() { + configManager.Config.Postgres.Mode = "install" + configManager.Config.Postgres.Primary = &files.PostgresPrimaryConfig{ + IP: "10.50.0.2", + Hostname: "", + } + errors := configManager.ValidateInstallConfig() + Expect(errors).To(ContainElement(ContainSubstring("postgres primary hostname is required"))) + }) + }) + + Context("external mode", func() { + It("should require server address", func() { + configManager.Config.Postgres.Mode = "external" + configManager.Config.Postgres.ServerAddress = "" + errors := configManager.ValidateInstallConfig() + Expect(errors).To(ContainElement(ContainSubstring("postgres server address is required"))) + }) + }) + }) + + Context("ceph validation", func() { + It("should require at least one Ceph host", func() { + configManager.Config.Ceph.Hosts = []files.CephHost{} + errors := configManager.ValidateInstallConfig() + Expect(errors).To(ContainElement(ContainSubstring("at least one Ceph host is required"))) + }) + + It("should validate Ceph host IPs", func() { + configManager.Config.Ceph.Hosts = []files.CephHost{ + {Hostname: "ceph-0", IPAddress: "invalid-ip", IsMaster: true}, + } + errors := configManager.ValidateInstallConfig() + Expect(errors).To(ContainElement(ContainSubstring("invalid Ceph host IP"))) + }) + }) + + Context("kubernetes validation", func() { + Context("managed by Codesphere", func() { + BeforeEach(func() { + configManager.Config.Kubernetes.ManagedByCodesphere = true + }) + + It("should require at least one control plane node", func() { + configManager.Config.Kubernetes.ControlPlanes = []files.K8sNode{} + errors := configManager.ValidateInstallConfig() + Expect(errors).To(ContainElement(ContainSubstring("at least one K8s control plane node is required"))) + }) + }) + + Context("external cluster", func() { + BeforeEach(func() { + configManager.Config.Kubernetes.ManagedByCodesphere = false + }) + + It("should require pod CIDR", func() { + configManager.Config.Kubernetes.PodCIDR = "" + errors := configManager.ValidateInstallConfig() + Expect(errors).To(ContainElement(ContainSubstring("pod CIDR is required"))) + }) + + It("should require service CIDR", func() { + configManager.Config.Kubernetes.ServiceCIDR = "" + errors := configManager.ValidateInstallConfig() + Expect(errors).To(ContainElement(ContainSubstring("service CIDR is required"))) + }) + }) + }) + + Context("codesphere validation", func() { + It("should require Codesphere domain", func() { + configManager.Config.Codesphere.Domain = "" + errors := configManager.ValidateInstallConfig() + Expect(errors).To(ContainElement(ContainSubstring("Codesphere domain is required"))) + }) + }) + + Context("with valid configuration", func() { + BeforeEach(func() { + configManager.Config = &files.RootConfig{ + Datacenter: files.DatacenterConfig{ + ID: 1, + Name: "test-dc", + }, + Postgres: files.PostgresConfig{ + Mode: "install", + Primary: &files.PostgresPrimaryConfig{ + IP: "10.50.0.2", + Hostname: "pg-primary", + }, + }, + Ceph: files.CephConfig{ + Hosts: []files.CephHost{ + {Hostname: "ceph-0", IPAddress: "10.53.101.2", IsMaster: true}, + }, + }, + Kubernetes: files.KubernetesConfig{ + ManagedByCodesphere: true, + ControlPlanes: []files.K8sNode{{IPAddress: "10.50.0.2"}}, + }, + Codesphere: files.CodesphereConfig{ + Domain: "codesphere.example.com", + }, + } + }) + + It("should return no errors", func() { + errors := configManager.ValidateInstallConfig() + Expect(errors).To(BeEmpty()) + }) + }) + }) + + Describe("ValidateVault", func() { + Context("with nil vault", func() { + It("should return error", func() { + configManager.Vault = nil + errors := configManager.ValidateVault() + Expect(errors).To(HaveLen(1)) + Expect(errors[0]).To(ContainSubstring("vault not set")) + }) + }) + + Context("with empty vault", func() { + It("should return errors for missing required secrets", func() { + configManager.Vault = &files.InstallVault{ + Secrets: []files.SecretEntry{}, + } + errors := configManager.ValidateVault() + Expect(errors).ToNot(BeEmpty()) + Expect(errors).To(ContainElement(ContainSubstring("cephSshPrivateKey"))) + Expect(errors).To(ContainElement(ContainSubstring("selfSignedCaKeyPem"))) + Expect(errors).To(ContainElement(ContainSubstring("domainAuthPrivateKey"))) + Expect(errors).To(ContainElement(ContainSubstring("domainAuthPublicKey"))) + }) + }) + + Context("with valid vault", func() { + BeforeEach(func() { + configManager.Vault = &files.InstallVault{ + Secrets: []files.SecretEntry{ + {Name: "cephSshPrivateKey"}, + {Name: "selfSignedCaKeyPem"}, + {Name: "domainAuthPrivateKey"}, + {Name: "domainAuthPublicKey"}, + }, + } + }) + + It("should return no errors", func() { + errors := configManager.ValidateVault() + Expect(errors).To(BeEmpty()) + }) + }) + + Context("with partial vault", func() { + It("should return errors for missing secrets only", func() { + configManager.Vault = &files.InstallVault{ + Secrets: []files.SecretEntry{ + {Name: "cephSshPrivateKey"}, + {Name: "selfSignedCaKeyPem"}, + }, + } + errors := configManager.ValidateVault() + Expect(errors).To(HaveLen(2)) + Expect(errors).To(ContainElement(ContainSubstring("domainAuthPrivateKey"))) + Expect(errors).To(ContainElement(ContainSubstring("domainAuthPublicKey"))) + }) + }) + }) + + Describe("GetInstallConfig", func() { + It("should return the current config", func() { + testConfig := &files.RootConfig{ + Datacenter: files.DatacenterConfig{ + ID: 42, + Name: "test", + }, + } + configManager.Config = testConfig + + result := configManager.GetInstallConfig() + Expect(result).To(Equal(testConfig)) + Expect(result.Datacenter.ID).To(Equal(42)) + }) + }) + + Describe("AddConfigComments", func() { + It("should add header comments to YAML data", func() { + yamlData := []byte("datacenter:\n id: 1\n") + result := installer.AddConfigComments(yamlData) + + resultStr := string(result) + Expect(resultStr).To(ContainSubstring("Codesphere Installer Configuration")) + Expect(resultStr).To(ContainSubstring("Generated by OMS CLI")) + Expect(resultStr).To(ContainSubstring("datacenter:")) + }) + }) + + Describe("AddVaultComments", func() { + It("should add security warning header to vault YAML", func() { + yamlData := []byte("secrets:\n - name: test\n") + result := installer.AddVaultComments(yamlData) + + resultStr := string(result) + Expect(resultStr).To(ContainSubstring("Codesphere Installer Secrets")) + Expect(resultStr).To(ContainSubstring("IMPORTANT: This file contains sensitive information!")) + Expect(resultStr).To(ContainSubstring("SOPS")) + Expect(resultStr).To(ContainSubstring("Age")) + Expect(resultStr).To(ContainSubstring("secrets:")) + }) + }) + + Describe("WriteInstallConfig", func() { + It("should return error if config is nil", func() { + configManager.Config = nil + err := configManager.WriteInstallConfig("/tmp/config.yaml", false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("no configuration provided")) + }) + }) + + Describe("WriteVault", func() { + It("should return error if config is nil", func() { + configManager.Config = nil + err := configManager.WriteVault("/tmp/vault.yaml", false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("no configuration provided")) + }) + }) + + Describe("Integration Tests", func() { + Context("full configuration lifecycle", func() { + It("should apply profile, validate, and prepare for write", func() { + err := configManager.ApplyProfile("dev") + Expect(err).ToNot(HaveOccurred()) + + errors := configManager.ValidateInstallConfig() + Expect(errors).To(BeEmpty()) + + config := configManager.GetInstallConfig() + Expect(config).ToNot(BeNil()) + Expect(config.Datacenter.Name).To(Equal("dev")) + }) + + It("should generate secrets and create valid vault", func() { + err := configManager.ApplyProfile("prod") + Expect(err).ToNot(HaveOccurred()) + + err = configManager.GenerateSecrets() + Expect(err).ToNot(HaveOccurred()) + + vault := configManager.Config.ExtractVault() + configManager.Vault = vault + + errors := configManager.ValidateVault() + Expect(errors).To(BeEmpty()) + }) + }) + + }) +}) diff --git a/internal/installer/config_test.go b/internal/installer/config_test.go index 96f43df6..9e9225a1 100644 --- a/internal/installer/config_test.go +++ b/internal/installer/config_test.go @@ -71,7 +71,7 @@ codesphere: _, err := config.ParseConfigYaml(nonExistentFile) Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to parse config.yaml")) + Expect(err.Error()).To(ContainSubstring("failed to open config file")) }) }) diff --git a/internal/installer/crypto.go b/internal/installer/crypto.go new file mode 100644 index 00000000..0fbe829f --- /dev/null +++ b/internal/installer/crypto.go @@ -0,0 +1,221 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/pem" + "fmt" + "math/big" + "net" + "time" + + "golang.org/x/crypto/ssh" +) + +func GenerateSSHKeyPair() (privateKey string, publicKey string, err error) { + rsaKey, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + return "", "", err + } + + privKeyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(rsaKey), + }) + + sshPubKey, err := ssh.NewPublicKey(&rsaKey.PublicKey) + if err != nil { + return "", "", err + } + pubKeySSH := string(ssh.MarshalAuthorizedKey(sshPubKey)) + + return string(privKeyPEM), pubKeySSH, nil +} + +func GenerateCA(cn, country, locality, org string) (keyPEM string, certPEM string, err error) { + caKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return "", "", err + } + + serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return "", "", err + } + + template := &x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + CommonName: cn, + Country: []string{country}, + Locality: []string{locality}, + Organization: []string{org}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(3, 0, 0), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &caKey.PublicKey, caKey) + if err != nil { + return "", "", err + } + + keyPEM, err = encodePEMKey(caKey, "RSA") + if err != nil { + return "", "", err + } + + return keyPEM, encodePEMCert(certDER), nil +} + +func GenerateServerCertificate(caKeyPEM, caCertPEM, cn string, ipAddresses []string) (keyPEM string, certPEM string, err error) { + caKey, caCert, err := parseCAKeyAndCert(caKeyPEM, caCertPEM) + if err != nil { + return "", "", err + } + + serverKey, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + return "", "", err + } + + serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return "", "", err + } + + template := &x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + CommonName: cn, + Organization: []string{"Codesphere"}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(2, 0, 0), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + + for _, ip := range ipAddresses { + if parsed := net.ParseIP(ip); parsed != nil { + template.IPAddresses = append(template.IPAddresses, parsed) + } + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, caCert, &serverKey.PublicKey, caKey) + if err != nil { + return "", "", err + } + + keyPEM, err = encodePEMKey(serverKey, "RSA") + if err != nil { + return "", "", err + } + + return keyPEM, encodePEMCert(certDER), nil +} + +func GenerateECDSAKeyPair() (privateKey string, publicKey string, err error) { + ecKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return "", "", err + } + + privKeyPEM, err := encodePEMKey(ecKey, "EC") + if err != nil { + return "", "", err + } + + pubBytes, err := x509.MarshalPKIXPublicKey(&ecKey.PublicKey) + if err != nil { + return "", "", err + } + pubKeyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: pubBytes, + }) + + return privKeyPEM, string(pubKeyPEM), nil +} + +func GeneratePassword(length int) string { + bytes := make([]byte, length) + if _, err := rand.Read(bytes); err != nil { + return base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%d", time.Now().UnixNano()))) + } + return base64.StdEncoding.EncodeToString(bytes)[:length] +} + +func parseCAKeyAndCert(caKeyPEM, caCertPEM string) (*rsa.PrivateKey, *x509.Certificate, error) { + caKeyBlock, _ := pem.Decode([]byte(caKeyPEM)) + if caKeyBlock == nil { + return nil, nil, fmt.Errorf("failed to decode CA key PEM") + } + caKey, err := x509.ParsePKCS1PrivateKey(caKeyBlock.Bytes) + if err != nil { + return nil, nil, err + } + + caCertBlock, _ := pem.Decode([]byte(caCertPEM)) + if caCertBlock == nil { + return nil, nil, fmt.Errorf("failed to decode CA cert PEM") + } + caCert, err := x509.ParseCertificate(caCertBlock.Bytes) + if err != nil { + return nil, nil, err + } + + return caKey, caCert, nil +} + +func encodePEMKey(key interface{}, keyType string) (string, error) { + var pemBytes []byte + + switch keyType { + case "RSA": + rsaKey, ok := key.(*rsa.PrivateKey) + if !ok { + return "", fmt.Errorf("invalid RSA key type") + } + pemBytes = pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(rsaKey), + }) + case "EC": + ecKey, ok := key.(*ecdsa.PrivateKey) + if !ok { + return "", fmt.Errorf("invalid EC key type") + } + ecBytes, err := x509.MarshalECPrivateKey(ecKey) + if err != nil { + return "", err + } + pemBytes = pem.EncodeToMemory(&pem.Block{ + Type: "EC PRIVATE KEY", + Bytes: ecBytes, + }) + default: + return "", fmt.Errorf("unsupported key type: %s", keyType) + } + + return string(pemBytes), nil +} + +func encodePEMCert(certDER []byte) string { + return string(pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: certDER, + })) +} diff --git a/internal/installer/crypto_test.go b/internal/installer/crypto_test.go new file mode 100644 index 00000000..faeaa69e --- /dev/null +++ b/internal/installer/crypto_test.go @@ -0,0 +1,109 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "crypto/x509" + "encoding/pem" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "golang.org/x/crypto/ssh" +) + +var _ = Describe("GenerateSSHKeyPair", func() { + It("generates a valid SSH key pair", func() { + privKey, pubKey, err := GenerateSSHKeyPair() + Expect(err).NotTo(HaveOccurred()) + + Expect(privKey).To(HavePrefix("-----BEGIN RSA PRIVATE KEY-----")) + + _, _, _, _, err = ssh.ParseAuthorizedKey([]byte(pubKey)) + Expect(err).NotTo(HaveOccurred()) + + block, _ := pem.Decode([]byte(privKey)) + Expect(block).NotTo(BeNil()) + Expect(block.Type).To(Equal("RSA PRIVATE KEY")) + }) +}) + +var _ = Describe("GenerateCA", func() { + It("generates a valid CA certificate", func() { + keyPEM, certPEM, err := GenerateCA("Test CA", "DE", "Berlin", "TestOrg") + Expect(err).NotTo(HaveOccurred()) + + Expect(keyPEM).To(HavePrefix("-----BEGIN RSA PRIVATE KEY-----")) + Expect(certPEM).To(HavePrefix("-----BEGIN CERTIFICATE-----")) + + certBlock, _ := pem.Decode([]byte(certPEM)) + Expect(certBlock).NotTo(BeNil()) + + cert, err := x509.ParseCertificate(certBlock.Bytes) + Expect(err).NotTo(HaveOccurred()) + + Expect(cert.IsCA).To(BeTrue()) + Expect(cert.Subject.CommonName).To(Equal("Test CA")) + Expect(cert.Subject.Country).To(ContainElement("DE")) + Expect(cert.Subject.Locality).To(ContainElement("Berlin")) + Expect(cert.Subject.Organization).To(ContainElement("TestOrg")) + }) +}) + +var _ = Describe("GenerateServerCertificate", func() { + It("generates a valid server certificate", func() { + caKeyPEM, caCertPEM, err := GenerateCA("Test CA", "DE", "Berlin", "TestOrg") + Expect(err).NotTo(HaveOccurred()) + + serverKeyPEM, serverCertPEM, err := GenerateServerCertificate( + caKeyPEM, + caCertPEM, + "test-server", + []string{"192.168.1.1", "10.0.0.1"}, + ) + Expect(err).NotTo(HaveOccurred()) + + Expect(serverKeyPEM).To(HavePrefix("-----BEGIN RSA PRIVATE KEY-----")) + Expect(serverCertPEM).To(HavePrefix("-----BEGIN CERTIFICATE-----")) + + certBlock, _ := pem.Decode([]byte(serverCertPEM)) + Expect(certBlock).NotTo(BeNil()) + + cert, err := x509.ParseCertificate(certBlock.Bytes) + Expect(err).NotTo(HaveOccurred()) + + Expect(cert.Subject.CommonName).To(Equal("test-server")) + Expect(cert.IPAddresses).To(HaveLen(2)) + }) +}) + +var _ = Describe("GenerateECDSAKeyPair", func() { + It("generates a valid ECDSA key pair", func() { + privKey, pubKey, err := GenerateECDSAKeyPair() + Expect(err).NotTo(HaveOccurred()) + + Expect(privKey).To(HavePrefix("-----BEGIN EC PRIVATE KEY-----")) + Expect(pubKey).To(HavePrefix("-----BEGIN PUBLIC KEY-----")) + + privBlock, _ := pem.Decode([]byte(privKey)) + Expect(privBlock).NotTo(BeNil()) + Expect(privBlock.Type).To(Equal("EC PRIVATE KEY")) + + pubBlock, _ := pem.Decode([]byte(pubKey)) + Expect(pubBlock).NotTo(BeNil()) + Expect(pubBlock.Type).To(Equal("PUBLIC KEY")) + }) +}) + +var _ = Describe("GeneratePassword", func() { + It("generates passwords of the correct length", func() { + password := GeneratePassword(20) + Expect(password).To(HaveLen(20)) + }) + + It("generates different passwords", func() { + password1 := GeneratePassword(20) + password2 := GeneratePassword(20) + Expect(password1).NotTo(Equal(password2)) + }) +}) diff --git a/internal/installer/files/config_yaml.go b/internal/installer/files/config_yaml.go index d31eab68..f5e2f85a 100644 --- a/internal/installer/files/config_yaml.go +++ b/internal/installer/files/config_yaml.go @@ -5,23 +5,253 @@ package files import ( "fmt" - "os" + "strings" "gopkg.in/yaml.v3" ) +// Vault +type InstallVault struct { + Secrets []SecretEntry `yaml:"secrets"` +} + +func (v *InstallVault) Marshal() ([]byte, error) { + return yaml.Marshal(v) +} + +func (v *InstallVault) Unmarshal(data []byte) error { + return yaml.Unmarshal(data, v) +} + +type SecretEntry struct { + Name string `yaml:"name"` + File *SecretFile `yaml:"file,omitempty"` + Fields *SecretFields `yaml:"fields,omitempty"` +} + +type SecretFile struct { + Name string `yaml:"name"` + Content string `yaml:"content"` +} + +type SecretFields struct { + Password string `yaml:"password"` +} + // RootConfig represents the relevant parts of the configuration file type RootConfig struct { - Registry RegistryConfig `yaml:"registry"` - Codesphere CodesphereConfig `yaml:"codesphere"` + Datacenter DatacenterConfig `yaml:"dataCenter"` + Secrets SecretsConfig `yaml:"secrets"` + Registry RegistryConfig `yaml:"registry,omitempty"` + Postgres PostgresConfig `yaml:"postgres"` + Ceph CephConfig `yaml:"ceph"` + Kubernetes KubernetesConfig `yaml:"kubernetes"` + Cluster ClusterConfig `yaml:"cluster"` + MetalLB *MetalLBConfig `yaml:"metallb,omitempty"` + Codesphere CodesphereConfig `yaml:"codesphere"` + ManagedServiceBackends *ManagedServiceBackendsConfig `yaml:"managedServiceBackends,omitempty"` +} + +type DatacenterConfig struct { + ID int `yaml:"id"` + Name string `yaml:"name"` + City string `yaml:"city"` + CountryCode string `yaml:"countryCode"` +} + +type SecretsConfig struct { + BaseDir string `yaml:"baseDir"` } type RegistryConfig struct { - Server string `yaml:"server"` + Server string `yaml:"server"` + ReplaceImagesInBom bool `yaml:"replaceImagesInBom"` + LoadContainerImages bool `yaml:"loadContainerImages"` +} + +type PostgresConfig struct { + Mode string `yaml:"mode,omitempty"` + CACertPem string `yaml:"caCertPem,omitempty"` + Primary *PostgresPrimaryConfig `yaml:"primary,omitempty"` + Replica *PostgresReplicaConfig `yaml:"replica,omitempty"` + ServerAddress string `yaml:"serverAddress,omitempty"` + + // Stored separately in vault + CaCertPrivateKey string `yaml:"-"` + AdminPassword string `yaml:"-"` + ReplicaPassword string `yaml:"-"` + UserPasswords map[string]string `yaml:"-"` +} + +type PostgresPrimaryConfig struct { + SSLConfig SSLConfig `yaml:"sslConfig"` + IP string `yaml:"ip"` + Hostname string `yaml:"hostname"` + + PrivateKey string `yaml:"-"` +} + +type PostgresReplicaConfig struct { + IP string `yaml:"ip"` + Name string `yaml:"name"` + SSLConfig SSLConfig `yaml:"sslConfig"` + + PrivateKey string `yaml:"-"` +} + +type SSLConfig struct { + ServerCertPem string `yaml:"serverCertPem"` +} + +type CephConfig struct { + CsiKubeletDir string `yaml:"csiKubeletDir,omitempty"` + CephAdmSSHKey CephSSHKey `yaml:"cephAdmSshKey"` + NodesSubnet string `yaml:"nodesSubnet"` + Hosts []CephHost `yaml:"hosts"` + OSDs []CephOSD `yaml:"osds"` + + SshPrivateKey string `yaml:"-"` +} + +type CephSSHKey struct { + PublicKey string `yaml:"publicKey"` +} + +type CephHost struct { + Hostname string `yaml:"hostname"` + IPAddress string `yaml:"ipAddress"` + IsMaster bool `yaml:"isMaster"` +} + +type CephOSD struct { + SpecID string `yaml:"specId"` + Placement CephPlacement `yaml:"placement"` + DataDevices CephDataDevices `yaml:"dataDevices"` + DBDevices CephDBDevices `yaml:"dbDevices"` +} + +type CephPlacement struct { + HostPattern string `yaml:"host_pattern"` +} + +type CephDataDevices struct { + Size string `yaml:"size"` + Limit int `yaml:"limit"` +} + +type CephDBDevices struct { + Size string `yaml:"size"` + Limit int `yaml:"limit"` +} + +type KubernetesConfig struct { + ManagedByCodesphere bool `yaml:"managedByCodesphere"` + APIServerHost string `yaml:"apiServerHost,omitempty"` + ControlPlanes []K8sNode `yaml:"controlPlanes,omitempty"` + Workers []K8sNode `yaml:"workers,omitempty"` + PodCIDR string `yaml:"podCidr,omitempty"` + ServiceCIDR string `yaml:"serviceCidr,omitempty"` + + // Internal flag + NeedsKubeConfig bool `yaml:"-"` +} + +type K8sNode struct { + IPAddress string `yaml:"ipAddress"` +} + +type ClusterConfig struct { + Certificates ClusterCertificates `yaml:"certificates"` + Monitoring *MonitoringConfig `yaml:"monitoring,omitempty"` + Gateway GatewayConfig `yaml:"gateway"` + PublicGateway GatewayConfig `yaml:"publicGateway"` + + IngressCAKey string `yaml:"-"` +} + +type ClusterCertificates struct { + CA CAConfig `yaml:"ca"` +} + +type CAConfig struct { + Algorithm string `yaml:"algorithm"` + KeySizeBits int `yaml:"keySizeBits"` + CertPem string `yaml:"certPem"` +} + +type GatewayConfig struct { + ServiceType string `yaml:"serviceType"` + Annotations map[string]string `yaml:"annotations,omitempty"` + IPAddresses []string `yaml:"ipAddresses,omitempty"` +} + +type MetalLBConfig struct { + Enabled bool `yaml:"enabled"` + Pools []MetalLBPoolDef `yaml:"pools"` + L2 []MetalLBL2 `yaml:"l2,omitempty"` + BGP []MetalLBBGP `yaml:"bgp,omitempty"` +} + +type MetalLBPoolDef struct { + Name string `yaml:"name"` + IPAddresses []string `yaml:"ipAddresses"` +} + +type MetalLBL2 struct { + Name string `yaml:"name"` + Pools []string `yaml:"pools"` + NodeSelectors []map[string]string `yaml:"nodeSelectors,omitempty"` +} + +type MetalLBBGP struct { + Name string `yaml:"name"` + Pools []string `yaml:"pools"` + Config MetalLBBGPConfig `yaml:"config"` + NodeSelectors []map[string]string `yaml:"nodeSelectors,omitempty"` +} + +type MetalLBBGPConfig struct { + MyASN int `yaml:"myASN"` + PeerASN int `yaml:"peerASN"` + PeerAddress string `yaml:"peerAddress"` + BFDProfile string `yaml:"bfdProfile,omitempty"` } type CodesphereConfig struct { - DeployConfig DeployConfig `yaml:"deployConfig"` + Domain string `yaml:"domain"` + WorkspaceHostingBaseDomain string `yaml:"workspaceHostingBaseDomain"` + PublicIP string `yaml:"publicIp"` + CustomDomains CustomDomainsConfig `yaml:"customDomains"` + DNSServers []string `yaml:"dnsServers"` + Experiments []string `yaml:"experiments"` + ExtraCAPem string `yaml:"extraCaPem,omitempty"` + ExtraWorkspaceEnvVars map[string]string `yaml:"extraWorkspaceEnvVars,omitempty"` + ExtraWorkspaceFiles []ExtraWorkspaceFile `yaml:"extraWorkspaceFiles,omitempty"` + WorkspaceImages *WorkspaceImagesConfig `yaml:"workspaceImages,omitempty"` + DeployConfig DeployConfig `yaml:"deployConfig"` + Plans PlansConfig `yaml:"plans"` + UnderprovisionFactors *UnderprovisionFactors `yaml:"underprovisionFactors,omitempty"` + GitProviders *GitProvidersConfig `yaml:"gitProviders,omitempty"` + ManagedServices []ManagedServiceConfig `yaml:"managedServices,omitempty"` + + DomainAuthPrivateKey string `yaml:"-"` + DomainAuthPublicKey string `yaml:"-"` +} + +type CustomDomainsConfig struct { + CNameBaseDomain string `yaml:"cNameBaseDomain"` +} + +type ExtraWorkspaceFile struct { + Path string `yaml:"path"` + Content string `yaml:"content"` +} + +type WorkspaceImagesConfig struct { + Agent *ImageRef `yaml:"agent,omitempty"` + AgentGpu *ImageRef `yaml:"agentGpu,omitempty"` + Server *ImageRef `yaml:"server,omitempty"` + VPN *ImageRef `yaml:"vpn,omitempty"` } type DeployConfig struct { @@ -44,18 +274,124 @@ type ImageRef struct { 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) - } +type PlansConfig struct { + HostingPlans map[int]HostingPlan `yaml:"hostingPlans"` + WorkspacePlans map[int]WorkspacePlan `yaml:"workspacePlans"` +} - err = yaml.Unmarshal(configData, c) - if err != nil { - return fmt.Errorf("failed to parse YAML config: %w", err) - } +type HostingPlan struct { + CPUTenth int `yaml:"cpuTenth"` + GPUParts int `yaml:"gpuParts"` + MemoryMb int `yaml:"memoryMb"` + StorageMb int `yaml:"storageMb"` + TempStorageMb int `yaml:"tempStorageMb"` +} + +type WorkspacePlan struct { + Name string `yaml:"name"` + HostingPlanID int `yaml:"hostingPlanId"` + MaxReplicas int `yaml:"maxReplicas"` + OnDemand bool `yaml:"onDemand"` +} + +type UnderprovisionFactors struct { + CPU float64 `yaml:"cpu"` + Memory float64 `yaml:"memory"` +} + +type GitProvidersConfig struct { + GitHub *GitProviderConfig `yaml:"github,omitempty"` + GitLab *GitProviderConfig `yaml:"gitlab,omitempty"` + Bitbucket *GitProviderConfig `yaml:"bitbucket,omitempty"` + AzureDevOps *GitProviderConfig `yaml:"azureDevOps,omitempty"` +} + +type GitProviderConfig struct { + Enabled bool `yaml:"enabled"` + URL string `yaml:"url"` + API APIConfig `yaml:"api"` + OAuth OAuthConfig `yaml:"oauth"` +} + +type APIConfig struct { + BaseURL string `yaml:"baseUrl"` +} + +type OAuthConfig struct { + Issuer string `yaml:"issuer"` + AuthorizationEndpoint string `yaml:"authorizationEndpoint"` + TokenEndpoint string `yaml:"tokenEndpoint"` + ClientAuthMethod string `yaml:"clientAuthMethod,omitempty"` + Scope string `yaml:"scope,omitempty"` +} + +type ManagedServiceConfig struct { + Name string `yaml:"name"` + API ManagedServiceAPI `yaml:"api"` + Author string `yaml:"author"` + Category string `yaml:"category"` + ConfigSchema map[string]interface{} `yaml:"configSchema"` + DetailsSchema map[string]interface{} `yaml:"detailsSchema"` + SecretsSchema map[string]interface{} `yaml:"secretsSchema"` + Description string `yaml:"description"` + DisplayName string `yaml:"displayName"` + IconURL string `yaml:"iconUrl"` + Plans []ServicePlan `yaml:"plans"` + Version string `yaml:"version"` +} + +type ManagedServiceAPI struct { + Endpoint string `yaml:"endpoint"` +} + +type ServicePlan struct { + ID int `yaml:"id"` + Description string `yaml:"description"` + Name string `yaml:"name"` + Parameters map[string]PlanParam `yaml:"parameters"` +} - return nil +type PlanParam struct { + PricedAs string `yaml:"pricedAs"` + Schema map[string]interface{} `yaml:"schema"` +} + +type ManagedServiceBackendsConfig struct { + Postgres map[string]interface{} `yaml:"postgres,omitempty"` +} + +type MonitoringConfig struct { + Prometheus *PrometheusConfig `yaml:"prometheus,omitempty"` +} + +type PrometheusConfig struct { + RemoteWrite *RemoteWriteConfig `yaml:"remoteWrite,omitempty"` +} + +type RemoteWriteConfig struct { + Enabled bool `yaml:"enabled"` + ClusterName string `yaml:"clusterName,omitempty"` +} + +type CephHostConfig struct { + Hostname string + IPAddress string + IsMaster bool +} + +type MetalLBPool struct { + Name string + IPAddresses []string +} + +// Marshal serializes the RootConfig to YAML +func (c *RootConfig) Marshal() ([]byte, error) { + return yaml.Marshal(c) +} + +// Unmarshal deserializes YAML data into the RootConfig +func (c *RootConfig) Unmarshal(data []byte) error { + return yaml.Unmarshal(data, c) } func (c *RootConfig) ExtractBomRefs() []string { @@ -82,3 +418,176 @@ func (c *RootConfig) ExtractWorkspaceDockerfiles() map[string]string { } return dockerfiles } + +func (c *RootConfig) ExtractVault() *InstallVault { + vault := &InstallVault{ + Secrets: []SecretEntry{}, + } + + c.addCodesphereSecrets(vault) + c.addIngressCASecret(vault) + c.addCephSecrets(vault) + c.addPostgresSecrets(vault) + c.addManagedServiceSecrets(vault) + c.addRegistrySecrets(vault) + c.addKubeConfigSecret(vault) + + return vault +} + +func (c *RootConfig) addCodesphereSecrets(vault *InstallVault) { + if c.Codesphere.DomainAuthPrivateKey != "" { + vault.Secrets = append(vault.Secrets, + SecretEntry{ + Name: "domainAuthPrivateKey", + File: &SecretFile{ + Name: "key.pem", + Content: c.Codesphere.DomainAuthPrivateKey, + }, + }, + SecretEntry{ + Name: "domainAuthPublicKey", + File: &SecretFile{ + Name: "key.pem", + Content: c.Codesphere.DomainAuthPublicKey, + }, + }, + ) + } +} + +func (c *RootConfig) addIngressCASecret(vault *InstallVault) { + if c.Cluster.IngressCAKey != "" { + vault.Secrets = append(vault.Secrets, SecretEntry{ + Name: "selfSignedCaKeyPem", + File: &SecretFile{ + Name: "key.pem", + Content: c.Cluster.IngressCAKey, + }, + }) + } +} + +func (c *RootConfig) addCephSecrets(vault *InstallVault) { + if c.Ceph.SshPrivateKey != "" { + vault.Secrets = append(vault.Secrets, SecretEntry{ + Name: "cephSshPrivateKey", + File: &SecretFile{ + Name: "id_rsa", + Content: c.Ceph.SshPrivateKey, + }, + }) + } +} + +func (c *RootConfig) addPostgresSecrets(vault *InstallVault) { + if c.Postgres.Primary == nil { + return + } + + if c.Postgres.AdminPassword != "" { + vault.Secrets = append(vault.Secrets, SecretEntry{ + Name: "postgresPassword", + Fields: &SecretFields{ + Password: c.Postgres.AdminPassword, + }, + }) + } + + if c.Postgres.Primary.PrivateKey != "" { + vault.Secrets = append(vault.Secrets, SecretEntry{ + Name: "postgresPrimaryServerKeyPem", + File: &SecretFile{ + Name: "primary.key", + Content: c.Postgres.Primary.PrivateKey, + }, + }) + } + + if c.Postgres.Replica != nil { + if c.Postgres.ReplicaPassword != "" { + vault.Secrets = append(vault.Secrets, SecretEntry{ + Name: "postgresReplicaPassword", + Fields: &SecretFields{ + Password: c.Postgres.ReplicaPassword, + }, + }) + } + + if c.Postgres.Replica.PrivateKey != "" { + vault.Secrets = append(vault.Secrets, SecretEntry{ + Name: "postgresReplicaServerKeyPem", + File: &SecretFile{ + Name: "replica.key", + Content: c.Postgres.Replica.PrivateKey, + }, + }) + } + } + + services := []string{"auth", "deployment", "ide", "marketplace", "payment", "public_api", "team", "workspace"} + for _, service := range services { + vault.Secrets = append(vault.Secrets, SecretEntry{ + Name: fmt.Sprintf("postgresUser%s", capitalize(service)), + Fields: &SecretFields{ + Password: service + "_blue", + }, + }) + if password, ok := c.Postgres.UserPasswords[service]; ok { + vault.Secrets = append(vault.Secrets, SecretEntry{ + Name: fmt.Sprintf("postgresPassword%s", capitalize(service)), + Fields: &SecretFields{ + Password: password, + }, + }) + } + } +} + +func (c *RootConfig) addManagedServiceSecrets(vault *InstallVault) { + vault.Secrets = append(vault.Secrets, SecretEntry{ + Name: "managedServiceSecrets", + Fields: &SecretFields{ + Password: "[]", + }, + }) +} + +func (c *RootConfig) addRegistrySecrets(vault *InstallVault) { + if c.Registry.Server != "" { + vault.Secrets = append(vault.Secrets, + SecretEntry{ + Name: "registryUsername", + Fields: &SecretFields{ + Password: "YOUR_REGISTRY_USERNAME", + }, + }, + SecretEntry{ + Name: "registryPassword", + Fields: &SecretFields{ + Password: "YOUR_REGISTRY_PASSWORD", + }, + }, + ) + } +} + +func (c *RootConfig) addKubeConfigSecret(vault *InstallVault) { + if c.Kubernetes.NeedsKubeConfig { + vault.Secrets = append(vault.Secrets, SecretEntry{ + Name: "kubeConfig", + File: &SecretFile{ + Name: "kubeConfig", + Content: "# YOUR KUBECONFIG CONTENT HERE\n# Replace this with your actual kubeconfig for the external cluster\n", + }, + }) + } +} + +func capitalize(s string) string { + if s == "" { + return "" + } + s = strings.ReplaceAll(s, "_", "") + return strings.ToUpper(s[:1]) + s[1:] +} diff --git a/internal/installer/files/config_yaml_test.go b/internal/installer/files/config_yaml_test.go index 4c235a85..11620754 100644 --- a/internal/installer/files/config_yaml_test.go +++ b/internal/installer/files/config_yaml_test.go @@ -84,7 +84,10 @@ codesphere: err := os.WriteFile(configFile, []byte(sampleYaml), 0644) Expect(err).NotTo(HaveOccurred()) - err = rootConfig.ParseConfig(configFile) + data, err := os.ReadFile(configFile) + Expect(err).NotTo(HaveOccurred()) + + err = rootConfig.Unmarshal(data) Expect(err).NotTo(HaveOccurred()) Expect(rootConfig.Registry.Server).To(Equal("registry.example.com")) @@ -108,9 +111,8 @@ codesphere: }) It("should return error for non-existent file", func() { - err := rootConfig.ParseConfig("/non/existent/config.yaml") + _, err := os.ReadFile("/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() { @@ -124,16 +126,21 @@ codesphere: err := os.WriteFile(configFile, []byte(invalidYaml), 0644) Expect(err).NotTo(HaveOccurred()) - err = rootConfig.ParseConfig(configFile) + data, err := os.ReadFile(configFile) + Expect(err).NotTo(HaveOccurred()) + + err = rootConfig.Unmarshal(data) 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) + data, err := os.ReadFile(configFile) + Expect(err).NotTo(HaveOccurred()) + + err = rootConfig.Unmarshal(data) Expect(err).NotTo(HaveOccurred()) Expect(rootConfig.Registry.Server).To(BeEmpty()) @@ -150,7 +157,10 @@ codesphere: err := os.WriteFile(configFile, []byte(minimalYaml), 0644) Expect(err).NotTo(HaveOccurred()) - err = rootConfig.ParseConfig(configFile) + data, err := os.ReadFile(configFile) + Expect(err).NotTo(HaveOccurred()) + + err = rootConfig.Unmarshal(data) Expect(err).NotTo(HaveOccurred()) Expect(rootConfig.Registry.Server).To(Equal("minimal.registry.com")) @@ -163,7 +173,10 @@ codesphere: err := os.WriteFile(configFile, []byte(sampleYaml), 0644) Expect(err).NotTo(HaveOccurred()) - err = rootConfig.ParseConfig(configFile) + data, err := os.ReadFile(configFile) + Expect(err).NotTo(HaveOccurred()) + + err = rootConfig.Unmarshal(data) Expect(err).NotTo(HaveOccurred()) }) @@ -204,7 +217,10 @@ codesphere: err := os.WriteFile(configFile, []byte(yamlWithoutBomRefs), 0644) Expect(err).NotTo(HaveOccurred()) - err = noImagesConfig.ParseConfig(configFile) + data, err := os.ReadFile(configFile) + Expect(err).NotTo(HaveOccurred()) + + err = noImagesConfig.Unmarshal(data) Expect(err).NotTo(HaveOccurred()) bomRefs := noImagesConfig.ExtractBomRefs() @@ -217,7 +233,10 @@ codesphere: err := os.WriteFile(configFile, []byte(sampleYaml), 0644) Expect(err).NotTo(HaveOccurred()) - err = rootConfig.ParseConfig(configFile) + data, err := os.ReadFile(configFile) + Expect(err).NotTo(HaveOccurred()) + + err = rootConfig.Unmarshal(data) Expect(err).NotTo(HaveOccurred()) }) diff --git a/internal/installer/mocks.go b/internal/installer/mocks.go index 9779117e..a9adf6e7 100644 --- a/internal/installer/mocks.go +++ b/internal/installer/mocks.go @@ -91,6 +91,486 @@ func (_c *MockConfigManager_ParseConfigYaml_Call) RunAndReturn(run func(configPa return _c } +// NewMockInstallConfigManager creates a new instance of MockInstallConfigManager. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockInstallConfigManager(t interface { + mock.TestingT + Cleanup(func()) +}) *MockInstallConfigManager { + mock := &MockInstallConfigManager{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockInstallConfigManager is an autogenerated mock type for the InstallConfigManager type +type MockInstallConfigManager struct { + mock.Mock +} + +type MockInstallConfigManager_Expecter struct { + mock *mock.Mock +} + +func (_m *MockInstallConfigManager) EXPECT() *MockInstallConfigManager_Expecter { + return &MockInstallConfigManager_Expecter{mock: &_m.Mock} +} + +// ApplyProfile provides a mock function for the type MockInstallConfigManager +func (_mock *MockInstallConfigManager) ApplyProfile(profile string) error { + ret := _mock.Called(profile) + + if len(ret) == 0 { + panic("no return value specified for ApplyProfile") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string) error); ok { + r0 = returnFunc(profile) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockInstallConfigManager_ApplyProfile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ApplyProfile' +type MockInstallConfigManager_ApplyProfile_Call struct { + *mock.Call +} + +// ApplyProfile is a helper method to define mock.On call +// - profile +func (_e *MockInstallConfigManager_Expecter) ApplyProfile(profile interface{}) *MockInstallConfigManager_ApplyProfile_Call { + return &MockInstallConfigManager_ApplyProfile_Call{Call: _e.mock.On("ApplyProfile", profile)} +} + +func (_c *MockInstallConfigManager_ApplyProfile_Call) Run(run func(profile string)) *MockInstallConfigManager_ApplyProfile_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockInstallConfigManager_ApplyProfile_Call) Return(err error) *MockInstallConfigManager_ApplyProfile_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockInstallConfigManager_ApplyProfile_Call) RunAndReturn(run func(profile string) error) *MockInstallConfigManager_ApplyProfile_Call { + _c.Call.Return(run) + return _c +} + +// CollectInteractively provides a mock function for the type MockInstallConfigManager +func (_mock *MockInstallConfigManager) CollectInteractively() error { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for CollectInteractively") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func() error); ok { + r0 = returnFunc() + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockInstallConfigManager_CollectInteractively_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CollectInteractively' +type MockInstallConfigManager_CollectInteractively_Call struct { + *mock.Call +} + +// CollectInteractively is a helper method to define mock.On call +func (_e *MockInstallConfigManager_Expecter) CollectInteractively() *MockInstallConfigManager_CollectInteractively_Call { + return &MockInstallConfigManager_CollectInteractively_Call{Call: _e.mock.On("CollectInteractively")} +} + +func (_c *MockInstallConfigManager_CollectInteractively_Call) Run(run func()) *MockInstallConfigManager_CollectInteractively_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockInstallConfigManager_CollectInteractively_Call) Return(err error) *MockInstallConfigManager_CollectInteractively_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockInstallConfigManager_CollectInteractively_Call) RunAndReturn(run func() error) *MockInstallConfigManager_CollectInteractively_Call { + _c.Call.Return(run) + return _c +} + +// GenerateSecrets provides a mock function for the type MockInstallConfigManager +func (_mock *MockInstallConfigManager) GenerateSecrets() error { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for GenerateSecrets") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func() error); ok { + r0 = returnFunc() + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockInstallConfigManager_GenerateSecrets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GenerateSecrets' +type MockInstallConfigManager_GenerateSecrets_Call struct { + *mock.Call +} + +// GenerateSecrets is a helper method to define mock.On call +func (_e *MockInstallConfigManager_Expecter) GenerateSecrets() *MockInstallConfigManager_GenerateSecrets_Call { + return &MockInstallConfigManager_GenerateSecrets_Call{Call: _e.mock.On("GenerateSecrets")} +} + +func (_c *MockInstallConfigManager_GenerateSecrets_Call) Run(run func()) *MockInstallConfigManager_GenerateSecrets_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockInstallConfigManager_GenerateSecrets_Call) Return(err error) *MockInstallConfigManager_GenerateSecrets_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockInstallConfigManager_GenerateSecrets_Call) RunAndReturn(run func() error) *MockInstallConfigManager_GenerateSecrets_Call { + _c.Call.Return(run) + return _c +} + +// GetInstallConfig provides a mock function for the type MockInstallConfigManager +func (_mock *MockInstallConfigManager) GetInstallConfig() *files.RootConfig { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for GetInstallConfig") + } + + var r0 *files.RootConfig + if returnFunc, ok := ret.Get(0).(func() *files.RootConfig); ok { + r0 = returnFunc() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*files.RootConfig) + } + } + return r0 +} + +// MockInstallConfigManager_GetInstallConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetInstallConfig' +type MockInstallConfigManager_GetInstallConfig_Call struct { + *mock.Call +} + +// GetInstallConfig is a helper method to define mock.On call +func (_e *MockInstallConfigManager_Expecter) GetInstallConfig() *MockInstallConfigManager_GetInstallConfig_Call { + return &MockInstallConfigManager_GetInstallConfig_Call{Call: _e.mock.On("GetInstallConfig")} +} + +func (_c *MockInstallConfigManager_GetInstallConfig_Call) Run(run func()) *MockInstallConfigManager_GetInstallConfig_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockInstallConfigManager_GetInstallConfig_Call) Return(rootConfig *files.RootConfig) *MockInstallConfigManager_GetInstallConfig_Call { + _c.Call.Return(rootConfig) + return _c +} + +func (_c *MockInstallConfigManager_GetInstallConfig_Call) RunAndReturn(run func() *files.RootConfig) *MockInstallConfigManager_GetInstallConfig_Call { + _c.Call.Return(run) + return _c +} + +// LoadInstallConfigFromFile provides a mock function for the type MockInstallConfigManager +func (_mock *MockInstallConfigManager) LoadInstallConfigFromFile(configPath string) error { + ret := _mock.Called(configPath) + + if len(ret) == 0 { + panic("no return value specified for LoadInstallConfigFromFile") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string) error); ok { + r0 = returnFunc(configPath) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockInstallConfigManager_LoadInstallConfigFromFile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LoadInstallConfigFromFile' +type MockInstallConfigManager_LoadInstallConfigFromFile_Call struct { + *mock.Call +} + +// LoadInstallConfigFromFile is a helper method to define mock.On call +// - configPath +func (_e *MockInstallConfigManager_Expecter) LoadInstallConfigFromFile(configPath interface{}) *MockInstallConfigManager_LoadInstallConfigFromFile_Call { + return &MockInstallConfigManager_LoadInstallConfigFromFile_Call{Call: _e.mock.On("LoadInstallConfigFromFile", configPath)} +} + +func (_c *MockInstallConfigManager_LoadInstallConfigFromFile_Call) Run(run func(configPath string)) *MockInstallConfigManager_LoadInstallConfigFromFile_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockInstallConfigManager_LoadInstallConfigFromFile_Call) Return(err error) *MockInstallConfigManager_LoadInstallConfigFromFile_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockInstallConfigManager_LoadInstallConfigFromFile_Call) RunAndReturn(run func(configPath string) error) *MockInstallConfigManager_LoadInstallConfigFromFile_Call { + _c.Call.Return(run) + return _c +} + +// LoadVaultFromFile provides a mock function for the type MockInstallConfigManager +func (_mock *MockInstallConfigManager) LoadVaultFromFile(vaultPath string) error { + ret := _mock.Called(vaultPath) + + if len(ret) == 0 { + panic("no return value specified for LoadVaultFromFile") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string) error); ok { + r0 = returnFunc(vaultPath) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockInstallConfigManager_LoadVaultFromFile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LoadVaultFromFile' +type MockInstallConfigManager_LoadVaultFromFile_Call struct { + *mock.Call +} + +// LoadVaultFromFile is a helper method to define mock.On call +// - vaultPath +func (_e *MockInstallConfigManager_Expecter) LoadVaultFromFile(vaultPath interface{}) *MockInstallConfigManager_LoadVaultFromFile_Call { + return &MockInstallConfigManager_LoadVaultFromFile_Call{Call: _e.mock.On("LoadVaultFromFile", vaultPath)} +} + +func (_c *MockInstallConfigManager_LoadVaultFromFile_Call) Run(run func(vaultPath string)) *MockInstallConfigManager_LoadVaultFromFile_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockInstallConfigManager_LoadVaultFromFile_Call) Return(err error) *MockInstallConfigManager_LoadVaultFromFile_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockInstallConfigManager_LoadVaultFromFile_Call) RunAndReturn(run func(vaultPath string) error) *MockInstallConfigManager_LoadVaultFromFile_Call { + _c.Call.Return(run) + return _c +} + +// ValidateInstallConfig provides a mock function for the type MockInstallConfigManager +func (_mock *MockInstallConfigManager) ValidateInstallConfig() []string { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for ValidateInstallConfig") + } + + var r0 []string + if returnFunc, ok := ret.Get(0).(func() []string); ok { + r0 = returnFunc() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + return r0 +} + +// MockInstallConfigManager_ValidateInstallConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ValidateInstallConfig' +type MockInstallConfigManager_ValidateInstallConfig_Call struct { + *mock.Call +} + +// ValidateInstallConfig is a helper method to define mock.On call +func (_e *MockInstallConfigManager_Expecter) ValidateInstallConfig() *MockInstallConfigManager_ValidateInstallConfig_Call { + return &MockInstallConfigManager_ValidateInstallConfig_Call{Call: _e.mock.On("ValidateInstallConfig")} +} + +func (_c *MockInstallConfigManager_ValidateInstallConfig_Call) Run(run func()) *MockInstallConfigManager_ValidateInstallConfig_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockInstallConfigManager_ValidateInstallConfig_Call) Return(strings []string) *MockInstallConfigManager_ValidateInstallConfig_Call { + _c.Call.Return(strings) + return _c +} + +func (_c *MockInstallConfigManager_ValidateInstallConfig_Call) RunAndReturn(run func() []string) *MockInstallConfigManager_ValidateInstallConfig_Call { + _c.Call.Return(run) + return _c +} + +// ValidateVault provides a mock function for the type MockInstallConfigManager +func (_mock *MockInstallConfigManager) ValidateVault() []string { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for ValidateVault") + } + + var r0 []string + if returnFunc, ok := ret.Get(0).(func() []string); ok { + r0 = returnFunc() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + return r0 +} + +// MockInstallConfigManager_ValidateVault_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ValidateVault' +type MockInstallConfigManager_ValidateVault_Call struct { + *mock.Call +} + +// ValidateVault is a helper method to define mock.On call +func (_e *MockInstallConfigManager_Expecter) ValidateVault() *MockInstallConfigManager_ValidateVault_Call { + return &MockInstallConfigManager_ValidateVault_Call{Call: _e.mock.On("ValidateVault")} +} + +func (_c *MockInstallConfigManager_ValidateVault_Call) Run(run func()) *MockInstallConfigManager_ValidateVault_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockInstallConfigManager_ValidateVault_Call) Return(strings []string) *MockInstallConfigManager_ValidateVault_Call { + _c.Call.Return(strings) + return _c +} + +func (_c *MockInstallConfigManager_ValidateVault_Call) RunAndReturn(run func() []string) *MockInstallConfigManager_ValidateVault_Call { + _c.Call.Return(run) + return _c +} + +// WriteInstallConfig provides a mock function for the type MockInstallConfigManager +func (_mock *MockInstallConfigManager) WriteInstallConfig(configPath string, withComments bool) error { + ret := _mock.Called(configPath, withComments) + + if len(ret) == 0 { + panic("no return value specified for WriteInstallConfig") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string, bool) error); ok { + r0 = returnFunc(configPath, withComments) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockInstallConfigManager_WriteInstallConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WriteInstallConfig' +type MockInstallConfigManager_WriteInstallConfig_Call struct { + *mock.Call +} + +// WriteInstallConfig is a helper method to define mock.On call +// - configPath +// - withComments +func (_e *MockInstallConfigManager_Expecter) WriteInstallConfig(configPath interface{}, withComments interface{}) *MockInstallConfigManager_WriteInstallConfig_Call { + return &MockInstallConfigManager_WriteInstallConfig_Call{Call: _e.mock.On("WriteInstallConfig", configPath, withComments)} +} + +func (_c *MockInstallConfigManager_WriteInstallConfig_Call) Run(run func(configPath string, withComments bool)) *MockInstallConfigManager_WriteInstallConfig_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(bool)) + }) + return _c +} + +func (_c *MockInstallConfigManager_WriteInstallConfig_Call) Return(err error) *MockInstallConfigManager_WriteInstallConfig_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockInstallConfigManager_WriteInstallConfig_Call) RunAndReturn(run func(configPath string, withComments bool) error) *MockInstallConfigManager_WriteInstallConfig_Call { + _c.Call.Return(run) + return _c +} + +// WriteVault provides a mock function for the type MockInstallConfigManager +func (_mock *MockInstallConfigManager) WriteVault(vaultPath string, withComments bool) error { + ret := _mock.Called(vaultPath, withComments) + + if len(ret) == 0 { + panic("no return value specified for WriteVault") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string, bool) error); ok { + r0 = returnFunc(vaultPath, withComments) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockInstallConfigManager_WriteVault_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WriteVault' +type MockInstallConfigManager_WriteVault_Call struct { + *mock.Call +} + +// WriteVault is a helper method to define mock.On call +// - vaultPath +// - withComments +func (_e *MockInstallConfigManager_Expecter) WriteVault(vaultPath interface{}, withComments interface{}) *MockInstallConfigManager_WriteVault_Call { + return &MockInstallConfigManager_WriteVault_Call{Call: _e.mock.On("WriteVault", vaultPath, withComments)} +} + +func (_c *MockInstallConfigManager_WriteVault_Call) Run(run func(vaultPath string, withComments bool)) *MockInstallConfigManager_WriteVault_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(bool)) + }) + return _c +} + +func (_c *MockInstallConfigManager_WriteVault_Call) Return(err error) *MockInstallConfigManager_WriteVault_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockInstallConfigManager_WriteVault_Call) RunAndReturn(run func(vaultPath string, withComments bool) error) *MockInstallConfigManager_WriteVault_Call { + _c.Call.Return(run) + return _c +} + // NewMockPackageManager creates a new instance of MockPackageManager. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewMockPackageManager(t interface { diff --git a/internal/installer/prompt.go b/internal/installer/prompt.go new file mode 100644 index 00000000..91d44103 --- /dev/null +++ b/internal/installer/prompt.go @@ -0,0 +1,145 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "bufio" + "fmt" + "os" + "strconv" + "strings" +) + +type Prompter struct { + reader *bufio.Reader + interactive bool +} + +func NewPrompter(interactive bool) *Prompter { + return &Prompter{ + reader: bufio.NewReader(os.Stdin), + interactive: interactive, + } +} + +func (p *Prompter) String(prompt, defaultValue string) string { + if !p.interactive { + return defaultValue + } + + if defaultValue != "" { + fmt.Printf("%s (default: %s): ", prompt, defaultValue) + } else { + fmt.Printf("%s: ", prompt) + } + + input, _ := p.reader.ReadString('\n') + input = strings.TrimSpace(input) + + if input == "" { + return defaultValue + } + return input +} + +func (p *Prompter) Int(prompt string, defaultValue int) int { + if !p.interactive { + return defaultValue + } + + fmt.Printf("%s (default: %d): ", prompt, defaultValue) + + input, _ := p.reader.ReadString('\n') + input = strings.TrimSpace(input) + + if input == "" { + return defaultValue + } + + value, err := strconv.Atoi(input) + if err != nil { + fmt.Printf("Invalid number, using default: %d\n", defaultValue) + return defaultValue + } + return value +} + +func (p *Prompter) StringSlice(prompt string, defaultValue []string) []string { + if !p.interactive { + return defaultValue + } + + defaultStr := strings.Join(defaultValue, ", ") + if defaultStr != "" { + fmt.Printf("%s (default: %s): ", prompt, defaultStr) + } else { + fmt.Printf("%s: ", prompt) + } + + input, _ := p.reader.ReadString('\n') + input = strings.TrimSpace(input) + + if input == "" { + return defaultValue + } + + parts := strings.Split(input, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed != "" { + result = append(result, trimmed) + } + } + + if len(result) == 0 { + return defaultValue + } + return result +} + +func (p *Prompter) Bool(prompt string, defaultValue bool) bool { + if !p.interactive { + return defaultValue + } + + defaultStr := "n" + if defaultValue { + defaultStr = "y" + } + fmt.Printf("%s (y/n, default: %s): ", prompt, defaultStr) + + input, _ := p.reader.ReadString('\n') + input = strings.TrimSpace(strings.ToLower(input)) + + if input == "" { + return defaultValue + } + + return input == "y" || input == "yes" +} + +func (p *Prompter) Choice(prompt string, choices []string, defaultValue string) string { + if !p.interactive { + return defaultValue + } + + fmt.Printf("%s [%s] (default: %s): ", prompt, strings.Join(choices, "/"), defaultValue) + + input, _ := p.reader.ReadString('\n') + input = strings.TrimSpace(strings.ToLower(input)) + + if input == "" { + return defaultValue + } + + for _, choice := range choices { + if strings.ToLower(choice) == input { + return choice + } + } + + fmt.Printf("Invalid choice, using default: %s\n", defaultValue) + return defaultValue +} diff --git a/internal/installer/prompt_test.go b/internal/installer/prompt_test.go new file mode 100644 index 00000000..6b995cbf --- /dev/null +++ b/internal/installer/prompt_test.go @@ -0,0 +1,305 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "bufio" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Prompter", func() { + Describe("NewPrompter", func() { + It("creates a non-interactive prompter", func() { + p := NewPrompter(false) + Expect(p).NotTo(BeNil()) + Expect(p.interactive).To(BeFalse()) + Expect(p.reader).NotTo(BeNil()) + }) + + It("creates an interactive prompter", func() { + p := NewPrompter(true) + Expect(p).NotTo(BeNil()) + Expect(p.interactive).To(BeTrue()) + Expect(p.reader).NotTo(BeNil()) + }) + }) + + Describe("String", func() { + Context("non-interactive mode", func() { + It("returns default value without prompting", func() { + p := NewPrompter(false) + result := p.String("Enter value", "default") + Expect(result).To(Equal("default")) + }) + + It("returns empty string when no default", func() { + p := NewPrompter(false) + result := p.String("Enter value", "") + Expect(result).To(Equal("")) + }) + }) + + Context("interactive mode", func() { + It("returns user input when provided", func() { + input := "user-value\n" + p := &Prompter{ + reader: bufio.NewReader(strings.NewReader(input)), + interactive: true, + } + result := p.String("Enter value", "default") + Expect(result).To(Equal("user-value")) + }) + + It("returns default when input is empty", func() { + input := "\n" + p := &Prompter{ + reader: bufio.NewReader(strings.NewReader(input)), + interactive: true, + } + result := p.String("Enter value", "default") + Expect(result).To(Equal("default")) + }) + + It("trims whitespace from input", func() { + input := " value with spaces \n" + p := &Prompter{ + reader: bufio.NewReader(strings.NewReader(input)), + interactive: true, + } + result := p.String("Enter value", "default") + Expect(result).To(Equal("value with spaces")) + }) + }) + }) + + Describe("Int", func() { + Context("non-interactive mode", func() { + It("returns default value without prompting", func() { + p := NewPrompter(false) + result := p.Int("Enter number", 42) + Expect(result).To(Equal(42)) + }) + }) + + Context("interactive mode", func() { + It("returns parsed integer when valid input provided", func() { + input := "123\n" + p := &Prompter{ + reader: bufio.NewReader(strings.NewReader(input)), + interactive: true, + } + result := p.Int("Enter number", 42) + Expect(result).To(Equal(123)) + }) + + It("returns default when input is empty", func() { + input := "\n" + p := &Prompter{ + reader: bufio.NewReader(strings.NewReader(input)), + interactive: true, + } + result := p.Int("Enter number", 42) + Expect(result).To(Equal(42)) + }) + + It("returns default when input is invalid", func() { + input := "not-a-number\n" + p := &Prompter{ + reader: bufio.NewReader(strings.NewReader(input)), + interactive: true, + } + result := p.Int("Enter number", 42) + Expect(result).To(Equal(42)) + }) + + It("handles negative numbers", func() { + input := "-100\n" + p := &Prompter{ + reader: bufio.NewReader(strings.NewReader(input)), + interactive: true, + } + result := p.Int("Enter number", 0) + Expect(result).To(Equal(-100)) + }) + }) + }) + + Describe("StringSlice", func() { + Context("non-interactive mode", func() { + It("returns default value without prompting", func() { + p := NewPrompter(false) + defaultVal := []string{"one", "two", "three"} + result := p.StringSlice("Enter values", defaultVal) + Expect(result).To(Equal(defaultVal)) + }) + + It("returns empty slice when no default", func() { + p := NewPrompter(false) + result := p.StringSlice("Enter values", []string{}) + Expect(result).To(Equal([]string{})) + }) + }) + + Context("interactive mode", func() { + It("parses comma-separated values", func() { + input := "one, two, three\n" + p := &Prompter{ + reader: bufio.NewReader(strings.NewReader(input)), + interactive: true, + } + result := p.StringSlice("Enter values", []string{}) + Expect(result).To(Equal([]string{"one", "two", "three"})) + }) + + It("returns default when input is empty", func() { + input := "\n" + defaultVal := []string{"default1", "default2"} + p := &Prompter{ + reader: bufio.NewReader(strings.NewReader(input)), + interactive: true, + } + result := p.StringSlice("Enter values", defaultVal) + Expect(result).To(Equal(defaultVal)) + }) + + It("trims whitespace from each value", func() { + input := " one , two , three \n" + p := &Prompter{ + reader: bufio.NewReader(strings.NewReader(input)), + interactive: true, + } + result := p.StringSlice("Enter values", []string{}) + Expect(result).To(Equal([]string{"one", "two", "three"})) + }) + + It("handles single value", func() { + input := "single\n" + p := &Prompter{ + reader: bufio.NewReader(strings.NewReader(input)), + interactive: true, + } + result := p.StringSlice("Enter values", []string{}) + Expect(result).To(Equal([]string{"single"})) + }) + + It("filters out empty values", func() { + input := "one, , two, , three\n" + p := &Prompter{ + reader: bufio.NewReader(strings.NewReader(input)), + interactive: true, + } + result := p.StringSlice("Enter values", []string{}) + Expect(result).To(Equal([]string{"one", "two", "three"})) + }) + }) + }) + + Describe("Bool", func() { + Context("non-interactive mode", func() { + It("returns default true without prompting", func() { + p := NewPrompter(false) + result := p.Bool("Enable feature", true) + Expect(result).To(BeTrue()) + }) + + It("returns default false without prompting", func() { + p := NewPrompter(false) + result := p.Bool("Enable feature", false) + Expect(result).To(BeFalse()) + }) + }) + + Context("interactive mode", func() { + DescribeTable("boolean parsing", + func(input string, expected bool) { + p := &Prompter{ + reader: bufio.NewReader(strings.NewReader(input + "\n")), + interactive: true, + } + result := p.Bool("Enable feature", false) + Expect(result).To(Equal(expected)) + }, + Entry("'y' returns true", "y", true), + Entry("'Y' returns true", "Y", true), + Entry("'yes' returns true", "yes", true), + Entry("'YES' returns true", "YES", true), + Entry("'n' returns false", "n", false), + Entry("'N' returns false", "N", false), + Entry("'no' returns false", "no", false), + Entry("'NO' returns false", "NO", false), + Entry("invalid input returns false", "maybe", false), + ) + + It("returns default when input is empty", func() { + input := "\n" + p := &Prompter{ + reader: bufio.NewReader(strings.NewReader(input)), + interactive: true, + } + result := p.Bool("Enable feature", true) + Expect(result).To(BeTrue()) + }) + }) + }) + + Describe("Choice", func() { + Context("non-interactive mode", func() { + It("returns default value without prompting", func() { + p := NewPrompter(false) + choices := []string{"option1", "option2", "option3"} + result := p.Choice("Select option", choices, "option2") + Expect(result).To(Equal("option2")) + }) + }) + + Context("interactive mode", func() { + It("returns matching choice case-insensitively", func() { + input := "OPTION2\n" + choices := []string{"option1", "option2", "option3"} + p := &Prompter{ + reader: bufio.NewReader(strings.NewReader(input)), + interactive: true, + } + result := p.Choice("Select option", choices, "option1") + Expect(result).To(Equal("option2")) + }) + + It("returns default when input is empty", func() { + input := "\n" + choices := []string{"option1", "option2", "option3"} + p := &Prompter{ + reader: bufio.NewReader(strings.NewReader(input)), + interactive: true, + } + result := p.Choice("Select option", choices, "option2") + Expect(result).To(Equal("option2")) + }) + + It("returns default when input is invalid", func() { + input := "invalid-option\n" + choices := []string{"option1", "option2", "option3"} + p := &Prompter{ + reader: bufio.NewReader(strings.NewReader(input)), + interactive: true, + } + result := p.Choice("Select option", choices, "option1") + Expect(result).To(Equal("option1")) + }) + + It("handles exact match", func() { + input := "option2\n" + choices := []string{"option1", "option2", "option3"} + p := &Prompter{ + reader: bufio.NewReader(strings.NewReader(input)), + interactive: true, + } + result := p.Choice("Select option", choices, "option1") + Expect(result).To(Equal("option2")) + }) + }) + }) +}) diff --git a/internal/installer/secrets_test.go b/internal/installer/secrets_test.go new file mode 100644 index 00000000..14fbd4b1 --- /dev/null +++ b/internal/installer/secrets_test.go @@ -0,0 +1,186 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "strings" + + "github.com/codesphere-cloud/oms/internal/installer/files" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ExtractVault", func() { + It("extracts all secrets from config into vault format", func() { + config := &files.RootConfig{ + Postgres: files.PostgresConfig{ + CACertPem: "-----BEGIN CERTIFICATE-----\nPG-CA\n-----END CERTIFICATE-----", + CaCertPrivateKey: "-----BEGIN RSA PRIVATE KEY-----\nPG-CA-KEY\n-----END RSA PRIVATE KEY-----", + AdminPassword: "admin-pass-123", + ReplicaPassword: "replica-pass-456", + Primary: &files.PostgresPrimaryConfig{ + SSLConfig: files.SSLConfig{ + ServerCertPem: "-----BEGIN CERTIFICATE-----\nPG-PRIMARY\n-----END CERTIFICATE-----", + }, + IP: "10.50.0.2", + Hostname: "pg-primary", + PrivateKey: "-----BEGIN RSA PRIVATE KEY-----\nPG-PRIMARY-KEY\n-----END RSA PRIVATE KEY-----", + }, + Replica: &files.PostgresReplicaConfig{ + IP: "10.50.0.3", + Name: "replica1", + SSLConfig: files.SSLConfig{ + ServerCertPem: "-----BEGIN CERTIFICATE-----\nPG-REPLICA\n-----END CERTIFICATE-----", + }, + PrivateKey: "-----BEGIN RSA PRIVATE KEY-----\nPG-REPLICA-KEY\n-----END RSA PRIVATE KEY-----", + }, + UserPasswords: map[string]string{ + "auth": "auth-pass", + "deployment": "deploy-pass", + }, + }, + Ceph: files.CephConfig{ + SshPrivateKey: "-----BEGIN RSA PRIVATE KEY-----\nCEPH-SSH\n-----END RSA PRIVATE KEY-----", + }, + Cluster: files.ClusterConfig{ + IngressCAKey: "-----BEGIN RSA PRIVATE KEY-----\nINGRESS-CA-KEY\n-----END RSA PRIVATE KEY-----", + }, + Codesphere: files.CodesphereConfig{ + DomainAuthPrivateKey: "-----BEGIN EC PRIVATE KEY-----\nDOMAIN-AUTH-PRIV\n-----END EC PRIVATE KEY-----", + DomainAuthPublicKey: "-----BEGIN PUBLIC KEY-----\nDOMAIN-AUTH-PUB\n-----END PUBLIC KEY-----", + }, + Kubernetes: files.KubernetesConfig{ + NeedsKubeConfig: true, + }, + } + + vault := config.ExtractVault() + + Expect(vault.Secrets).NotTo(BeEmpty()) + + domainAuthPrivFound := false + domainAuthPubFound := false + for _, secret := range vault.Secrets { + if secret.Name == "domainAuthPrivateKey" { + domainAuthPrivFound = true + Expect(secret.File).NotTo(BeNil()) + Expect(secret.File.Content).To(ContainSubstring("DOMAIN-AUTH-PRIV")) + } + if secret.Name == "domainAuthPublicKey" { + domainAuthPubFound = true + Expect(secret.File).NotTo(BeNil()) + Expect(secret.File.Content).To(ContainSubstring("DOMAIN-AUTH-PUB")) + } + } + Expect(domainAuthPrivFound).To(BeTrue()) + Expect(domainAuthPubFound).To(BeTrue()) + + ingressCAFound := false + for _, secret := range vault.Secrets { + if secret.Name == "selfSignedCaKeyPem" { + ingressCAFound = true + Expect(secret.File.Content).To(ContainSubstring("INGRESS-CA-KEY")) + } + } + Expect(ingressCAFound).To(BeTrue()) + + cephSSHFound := false + for _, secret := range vault.Secrets { + if secret.Name == "cephSshPrivateKey" { + cephSSHFound = true + Expect(secret.File.Content).To(ContainSubstring("CEPH-SSH")) + } + } + Expect(cephSSHFound).To(BeTrue()) + + pgPasswordFound := false + pgUserPassFound := false + for _, secret := range vault.Secrets { + if secret.Name == "postgresPassword" { + pgPasswordFound = true + Expect(secret.Fields.Password).To(Equal("admin-pass-123")) + } + if len(secret.Name) > len("postgresPassword") && secret.Name[:16] == "postgresPassword" && secret.Name != "postgresPassword" { + pgUserPassFound = true + } + } + Expect(pgPasswordFound).To(BeTrue()) + Expect(pgUserPassFound).To(BeTrue()) + + kubeConfigFound := false + for _, secret := range vault.Secrets { + if secret.Name == "kubeConfig" { + kubeConfigFound = true + } + } + Expect(kubeConfigFound).To(BeTrue()) + }) + + It("does not include kubeconfig for managed k8s", func() { + config := &files.RootConfig{ + Kubernetes: files.KubernetesConfig{ + NeedsKubeConfig: false, + }, + Codesphere: files.CodesphereConfig{ + DomainAuthPrivateKey: "test-key", + DomainAuthPublicKey: "test-pub", + }, + } + + vault := config.ExtractVault() + + kubeConfigFound := false + for _, secret := range vault.Secrets { + if secret.Name == "kubeConfig" { + kubeConfigFound = true + } + } + Expect(kubeConfigFound).To(BeFalse()) + }) + + It("handles all postgres service passwords", func() { + services := []string{"auth", "deployment", "ide", "marketplace", "payment", "public_api", "team", "workspace"} + userPasswords := make(map[string]string) + for _, service := range services { + userPasswords[service] = service + "-pass" + } + + config := &files.RootConfig{ + Postgres: files.PostgresConfig{ + Primary: &files.PostgresPrimaryConfig{}, + UserPasswords: userPasswords, + }, + Codesphere: files.CodesphereConfig{ + DomainAuthPrivateKey: "test", + DomainAuthPublicKey: "test", + }, + } + + vault := config.ExtractVault() + + for _, service := range services { + foundUser := false + foundPass := false + for _, secret := range vault.Secrets { + if secret.Name == "postgresUser"+capitalize(service) { + foundUser = true + } + if secret.Name == "postgresPassword"+capitalize(service) { + foundPass = true + Expect(secret.Fields.Password).To(Equal(service + "-pass")) + } + } + Expect(foundUser).To(BeTrue(), "User secret for service %s not found", service) + Expect(foundPass).To(BeTrue(), "Password secret for service %s not found", service) + } + }) +}) + +func capitalize(s string) string { + if s == "" { + return "" + } + s = strings.ReplaceAll(s, "_", "") + return strings.ToUpper(s[:1]) + s[1:] +} diff --git a/internal/installer/utils_test.go b/internal/installer/utils_test.go new file mode 100644 index 00000000..d385f2c9 --- /dev/null +++ b/internal/installer/utils_test.go @@ -0,0 +1,50 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("IsValidIP", func() { + DescribeTable("IP validation", + func(ip string, valid bool) { + result := IsValidIP(ip) + Expect(result).To(Equal(valid)) + }, + Entry("valid IPv4", "192.168.1.1", true), + Entry("valid IPv6", "2001:db8::1", true), + Entry("invalid IP", "not-an-ip", false), + Entry("empty string", "", false), + Entry("partial IP", "192.168", false), + Entry("localhost", "127.0.0.1", true), + ) +}) + +var _ = Describe("AddConfigComments", func() { + It("adds header comments to config YAML", func() { + yamlData := []byte("test: value\n") + + result := AddConfigComments(yamlData) + resultStr := string(result) + + Expect(resultStr).To(ContainSubstring("Codesphere Installer Configuration")) + Expect(resultStr).To(ContainSubstring("test: value")) + }) +}) + +var _ = Describe("AddVaultComments", func() { + It("adds security warnings to vault YAML", func() { + yamlData := []byte("secrets:\n - name: test\n") + + result := AddVaultComments(yamlData) + resultStr := string(result) + + Expect(resultStr).To(ContainSubstring("Codesphere Installer Secrets")) + Expect(resultStr).To(ContainSubstring("IMPORTANT")) + Expect(resultStr).To(ContainSubstring("SOPS")) + Expect(resultStr).To(ContainSubstring("secrets:")) + }) +}) diff --git a/internal/tmpl/NOTICE b/internal/tmpl/NOTICE index f7a2e597..7ad9170d 100644 --- a/internal/tmpl/NOTICE +++ b/internal/tmpl/NOTICE @@ -17,9 +17,9 @@ License URL: https://github.com/clipperhouse/uax29/blob/v2.2.0/LICENSE ---------- Module: github.com/codesphere-cloud/cs-go/pkg/io -Version: v0.13.0 +Version: v0.14.0 License: Apache-2.0 -License URL: https://github.com/codesphere-cloud/cs-go/blob/v0.13.0/LICENSE +License URL: https://github.com/codesphere-cloud/cs-go/blob/v0.14.0/LICENSE ---------- Module: github.com/codesphere-cloud/oms/internal/tmpl diff --git a/internal/util/filewriter.go b/internal/util/filewriter.go index 5ac7b19f..48938df8 100644 --- a/internal/util/filewriter.go +++ b/internal/util/filewriter.go @@ -4,6 +4,7 @@ package util import ( + "fmt" "os" ) @@ -17,6 +18,7 @@ type FileIO interface { 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) + CreateAndWrite(filePath string, data []byte, fileType string) error } type FilesystemWriter struct{} @@ -29,6 +31,21 @@ func (fs *FilesystemWriter) Create(filename string) (*os.File, error) { return os.Create(filename) } +func (fs *FilesystemWriter) CreateAndWrite(filePath string, data []byte, fileType string) error { + file, err := fs.Create(filePath) + if err != nil { + return fmt.Errorf("failed to create %s file: %w", fileType, err) + } + defer CloseFileIgnoreError(file) + + if _, err = file.Write(data); err != nil { + return fmt.Errorf("failed to write %s file: %w", fileType, err) + } + + fmt.Printf("\n%s file created: %s\n", fileType, filePath) + return nil +} + func (fs *FilesystemWriter) Open(filename string) (*os.File, error) { return os.Open(filename) } diff --git a/internal/util/mocks.go b/internal/util/mocks.go index 00ac38b0..18015081 100644 --- a/internal/util/mocks.go +++ b/internal/util/mocks.go @@ -176,6 +176,53 @@ func (_c *MockFileIO_Create_Call) RunAndReturn(run func(filename string) (*os.Fi return _c } +// CreateAndWrite provides a mock function for the type MockFileIO +func (_mock *MockFileIO) CreateAndWrite(filePath string, data []byte, fileType string) error { + ret := _mock.Called(filePath, data, fileType) + + if len(ret) == 0 { + panic("no return value specified for CreateAndWrite") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string, []byte, string) error); ok { + r0 = returnFunc(filePath, data, fileType) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockFileIO_CreateAndWrite_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateAndWrite' +type MockFileIO_CreateAndWrite_Call struct { + *mock.Call +} + +// CreateAndWrite is a helper method to define mock.On call +// - filePath +// - data +// - fileType +func (_e *MockFileIO_Expecter) CreateAndWrite(filePath interface{}, data interface{}, fileType interface{}) *MockFileIO_CreateAndWrite_Call { + return &MockFileIO_CreateAndWrite_Call{Call: _e.mock.On("CreateAndWrite", filePath, data, fileType)} +} + +func (_c *MockFileIO_CreateAndWrite_Call) Run(run func(filePath string, data []byte, fileType string)) *MockFileIO_CreateAndWrite_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].([]byte), args[2].(string)) + }) + return _c +} + +func (_c *MockFileIO_CreateAndWrite_Call) Return(err error) *MockFileIO_CreateAndWrite_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockFileIO_CreateAndWrite_Call) RunAndReturn(run func(filePath string, data []byte, fileType string) error) *MockFileIO_CreateAndWrite_Call { + _c.Call.Return(run) + return _c +} + // Exists provides a mock function for the type MockFileIO func (_mock *MockFileIO) Exists(filename string) bool { ret := _mock.Called(filename)