diff --git a/cli/cmd/init.go b/cli/cmd/init.go new file mode 100644 index 0000000..f4f383c --- /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 0000000..7aad85e --- /dev/null +++ b/cli/cmd/init_install_config.go @@ -0,0 +1,441 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + "io" + "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 + Generator installer.InstallConfigManager +} + +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 + RegistryReplaceImages bool + RegistryLoadContainerImgs bool + + PostgresMode string + PostgresPrimaryIP string + PostgresPrimaryHost string + PostgresReplicaIP string + PostgresReplicaName string + PostgresExternal string + + CephSubnet string + CephHosts []files.CephHostConfig + + K8sManaged bool + K8sAPIServer string + K8sControlPlane []string + K8sWorkers []string + K8sExternalHost string + K8sPodCIDR string + K8sServiceCIDR string + + ClusterGatewayType string + ClusterGatewayIPs []string + ClusterPublicGatewayType string + ClusterPublicGatewayIPs []string + + MetalLBEnabled bool + MetalLBPools []files.MetalLBPool + + CodesphereDomain string + CodespherePublicIP string + CodesphereWorkspaceBaseDomain string + CodesphereCustomDomainBaseDomain string + CodesphereDNSServers []string + CodesphereWorkspaceImageBomRef string + CodesphereHostingPlanCPU int + CodesphereHostingPlanMemory int + CodesphereHostingPlanStorage int + CodesphereHostingPlanTempStorage int + CodesphereWorkspacePlanName string + CodesphereWorkspacePlanMaxReplica int +} + +// TODO: Implement this function that should be the only function in RunE +// func (c *InitInstallConfigCmd) CreateConfig(icm files.InstallConfigManager) error { +// if c.Opts.Interactive { +// _, err := icm.CollectConfiguration(c.cmd) +// if err != nil { +// return fmt.Errorf("failed to collect configuration: %w", err) +// } + +// icm.SetConfig(c.buildConfigOptions()) +// } else { +// icm.SetConfig(c.buildConfigOptions()) +// } + +// // icm.ApplyProfile(c.Opts.Profile) + +// // Create secrets + +// // Write config file + +// // Write vault file + +// return nil +// } + +func (c *InitInstallConfigCmd) RunE(_ *cobra.Command, args []string) error { + if c.Opts.ValidateOnly { + return c.validateConfig() + } + + if c.Opts.Profile != "" { + if err := c.applyProfile(); err != nil { + return fmt.Errorf("failed to apply profile: %w", err) + } + } + + fmt.Println("Welcome to OMS!") + fmt.Println("This wizard will help you create config.yaml and prod.vault.yaml for Codesphere installation.") + fmt.Println() + + configOpts := c.buildConfigOptions() + + _, err := c.Generator.CollectConfiguration(configOpts) + if err != nil { + return fmt.Errorf("failed to collect configuration: %w", err) + } + + if err := c.Generator.WriteConfigAndVault(c.Opts.ConfigFile, c.Opts.VaultFile, c.Opts.WithComments); err != nil { + return err + } + + c.printSuccessMessage() + + return nil +} + +func (c *InitInstallConfigCmd) buildConfigOptions() *files.ConfigOptions { + return &files.ConfigOptions{ + DatacenterID: c.Opts.DatacenterID, + DatacenterName: c.Opts.DatacenterName, + DatacenterCity: c.Opts.DatacenterCity, + DatacenterCountryCode: c.Opts.DatacenterCountryCode, + + RegistryServer: c.Opts.RegistryServer, + RegistryReplaceImages: c.Opts.RegistryReplaceImages, + RegistryLoadContainerImgs: c.Opts.RegistryLoadContainerImgs, + + PostgresMode: c.Opts.PostgresMode, + PostgresPrimaryIP: c.Opts.PostgresPrimaryIP, + PostgresPrimaryHost: c.Opts.PostgresPrimaryHost, + PostgresReplicaIP: c.Opts.PostgresReplicaIP, + PostgresReplicaName: c.Opts.PostgresReplicaName, + PostgresExternal: c.Opts.PostgresExternal, + + CephSubnet: c.Opts.CephSubnet, + CephHosts: c.Opts.CephHosts, + + K8sManaged: c.Opts.K8sManaged, + K8sAPIServer: c.Opts.K8sAPIServer, + K8sControlPlane: c.Opts.K8sControlPlane, + K8sWorkers: c.Opts.K8sWorkers, + K8sExternalHost: c.Opts.K8sExternalHost, + K8sPodCIDR: c.Opts.K8sPodCIDR, + K8sServiceCIDR: c.Opts.K8sServiceCIDR, + + ClusterGatewayType: c.Opts.ClusterGatewayType, + ClusterGatewayIPs: c.Opts.ClusterGatewayIPs, + ClusterPublicGatewayType: c.Opts.ClusterPublicGatewayType, + ClusterPublicGatewayIPs: c.Opts.ClusterPublicGatewayIPs, + + MetalLBEnabled: c.Opts.MetalLBEnabled, + MetalLBPools: c.Opts.MetalLBPools, + + CodesphereDomain: c.Opts.CodesphereDomain, + CodespherePublicIP: c.Opts.CodespherePublicIP, + CodesphereWorkspaceBaseDomain: c.Opts.CodesphereWorkspaceBaseDomain, + CodesphereCustomDomainBaseDomain: c.Opts.CodesphereCustomDomainBaseDomain, + CodesphereDNSServers: c.Opts.CodesphereDNSServers, + CodesphereWorkspaceImageBomRef: c.Opts.CodesphereWorkspaceImageBomRef, + CodesphereHostingPlanCPU: c.Opts.CodesphereHostingPlanCPU, + CodesphereHostingPlanMemory: c.Opts.CodesphereHostingPlanMemory, + CodesphereHostingPlanStorage: c.Opts.CodesphereHostingPlanStorage, + CodesphereHostingPlanTempStorage: c.Opts.CodesphereHostingPlanTempStorage, + CodesphereWorkspacePlanName: c.Opts.CodesphereWorkspacePlanName, + CodesphereWorkspacePlanMaxReplica: c.Opts.CodesphereWorkspacePlanMaxReplica, + + SecretsBaseDir: c.Opts.SecretsBaseDir, + } +} + +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) applyProfile() error { + switch strings.ToLower(c.Opts.Profile) { + case "dev", "development": + c.Opts.DatacenterID = 1 + c.Opts.DatacenterName = "dev" + c.Opts.DatacenterCity = "Karlsruhe" + c.Opts.DatacenterCountryCode = "DE" + c.Opts.PostgresMode = "install" + c.Opts.PostgresPrimaryIP = "127.0.0.1" + c.Opts.PostgresPrimaryHost = "localhost" + c.Opts.CephSubnet = "127.0.0.1/32" + c.Opts.CephHosts = []files.CephHostConfig{{Hostname: "localhost", IPAddress: "127.0.0.1", IsMaster: true}} + c.Opts.K8sManaged = true + c.Opts.K8sAPIServer = "127.0.0.1" + c.Opts.K8sControlPlane = []string{"127.0.0.1"} + c.Opts.K8sWorkers = []string{"127.0.0.1"} + c.Opts.ClusterGatewayType = "LoadBalancer" + c.Opts.ClusterPublicGatewayType = "LoadBalancer" + c.Opts.CodesphereDomain = "codesphere.local" + c.Opts.CodesphereWorkspaceBaseDomain = "ws.local" + c.Opts.CodesphereCustomDomainBaseDomain = "custom.local" + c.Opts.CodesphereDNSServers = []string{"8.8.8.8", "1.1.1.1"} + c.Opts.CodesphereWorkspaceImageBomRef = "workspace-agent-24.04" + c.Opts.CodesphereHostingPlanCPU = 10 + c.Opts.CodesphereHostingPlanMemory = 2048 + c.Opts.CodesphereHostingPlanStorage = 20480 + c.Opts.CodesphereHostingPlanTempStorage = 1024 + c.Opts.CodesphereWorkspacePlanName = "Standard Developer" + c.Opts.CodesphereWorkspacePlanMaxReplica = 3 + c.Opts.Interactive = false + c.Opts.GenerateKeys = true + c.Opts.SecretsBaseDir = "/root/secrets" + fmt.Println("Applied 'dev' profile: single-node development setup") + + case "prod", "production": + c.Opts.DatacenterID = 1 + c.Opts.DatacenterName = "production" + c.Opts.DatacenterCity = "Karlsruhe" + c.Opts.DatacenterCountryCode = "DE" + c.Opts.PostgresMode = "install" + c.Opts.PostgresPrimaryIP = "10.50.0.2" + c.Opts.PostgresPrimaryHost = "pg-primary" + c.Opts.PostgresReplicaIP = "10.50.0.3" + c.Opts.PostgresReplicaName = "replica1" + c.Opts.CephSubnet = "10.53.101.0/24" + c.Opts.CephHosts = []files.CephHostConfig{ + {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}, + } + c.Opts.K8sManaged = true + c.Opts.K8sAPIServer = "10.50.0.2" + c.Opts.K8sControlPlane = []string{"10.50.0.2"} + c.Opts.K8sWorkers = []string{"10.50.0.2", "10.50.0.3", "10.50.0.4"} + c.Opts.ClusterGatewayType = "LoadBalancer" + c.Opts.ClusterPublicGatewayType = "LoadBalancer" + c.Opts.CodesphereDomain = "codesphere.yourcompany.com" + c.Opts.CodesphereWorkspaceBaseDomain = "ws.yourcompany.com" + c.Opts.CodesphereCustomDomainBaseDomain = "custom.yourcompany.com" + c.Opts.CodesphereDNSServers = []string{"1.1.1.1", "8.8.8.8"} + c.Opts.CodesphereWorkspaceImageBomRef = "workspace-agent-24.04" + c.Opts.CodesphereHostingPlanCPU = 10 + c.Opts.CodesphereHostingPlanMemory = 2048 + c.Opts.CodesphereHostingPlanStorage = 20480 + c.Opts.CodesphereHostingPlanTempStorage = 1024 + c.Opts.CodesphereWorkspacePlanName = "Standard Developer" + c.Opts.CodesphereWorkspacePlanMaxReplica = 3 + c.Opts.GenerateKeys = true + c.Opts.SecretsBaseDir = "/root/secrets" + fmt.Println("Applied 'production' profile: HA multi-node setup") + + case "minimal": + c.Opts.DatacenterID = 1 + c.Opts.DatacenterName = "minimal" + c.Opts.DatacenterCity = "Karlsruhe" + c.Opts.DatacenterCountryCode = "DE" + c.Opts.PostgresMode = "install" + c.Opts.PostgresPrimaryIP = "127.0.0.1" + c.Opts.PostgresPrimaryHost = "localhost" + c.Opts.CephSubnet = "127.0.0.1/32" + c.Opts.CephHosts = []files.CephHostConfig{{Hostname: "localhost", IPAddress: "127.0.0.1", IsMaster: true}} + c.Opts.K8sManaged = true + c.Opts.K8sAPIServer = "127.0.0.1" + c.Opts.K8sControlPlane = []string{"127.0.0.1"} + c.Opts.K8sWorkers = []string{} + c.Opts.ClusterGatewayType = "LoadBalancer" + c.Opts.ClusterPublicGatewayType = "LoadBalancer" + c.Opts.CodesphereDomain = "codesphere.local" + c.Opts.CodesphereWorkspaceBaseDomain = "ws.local" + c.Opts.CodesphereCustomDomainBaseDomain = "custom.local" + c.Opts.CodesphereDNSServers = []string{"8.8.8.8"} + c.Opts.CodesphereWorkspaceImageBomRef = "workspace-agent-24.04" + c.Opts.CodesphereHostingPlanCPU = 10 + c.Opts.CodesphereHostingPlanMemory = 2048 + c.Opts.CodesphereHostingPlanStorage = 20480 + c.Opts.CodesphereHostingPlanTempStorage = 1024 + c.Opts.CodesphereWorkspacePlanName = "Standard Developer" + c.Opts.CodesphereWorkspacePlanMaxReplica = 1 + c.Opts.Interactive = false + c.Opts.GenerateKeys = true + c.Opts.SecretsBaseDir = "/root/secrets" + fmt.Println("Applied 'minimal' profile: minimal single-node setup") + + default: + return fmt.Errorf("unknown profile: %s. Available profiles: dev, production, minimal", c.Opts.Profile) + } + + return nil +} + +func (c *InitInstallConfigCmd) validateConfig() error { + fmt.Printf("Validating configuration files...\n") + + fmt.Printf("Reading config file: %s\n", c.Opts.ConfigFile) + configFile, err := c.FileWriter.Open(c.Opts.ConfigFile) + if err != nil { + return fmt.Errorf("failed to open config file: %w", err) + } + defer util.CloseFileIgnoreError(configFile) + + configData, err := io.ReadAll(configFile) + if err != nil { + return fmt.Errorf("failed to read config file: %w", err) + } + + config, err := installer.UnmarshalConfig(configData) + if err != nil { + return fmt.Errorf("failed to parse config.yaml: %w", err) + } + + errors := installer.ValidateConfig(config) + + if c.Opts.VaultFile != "" { + fmt.Printf("Reading vault file: %s\n", c.Opts.VaultFile) + vaultFile, err := c.FileWriter.Open(c.Opts.VaultFile) + if err != nil { + fmt.Printf("Warning: Could not open vault file: %v\n", err) + } else { + defer util.CloseFileIgnoreError(vaultFile) + + vaultData, err := io.ReadAll(vaultFile) + if err != nil { + errors = append(errors, fmt.Sprintf("failed to read vault.yaml: %v", err)) + } else { + vault, err := installer.UnmarshalVault(vaultData) + if err != nil { + errors = append(errors, fmt.Sprintf("failed to parse vault.yaml: %v", err)) + } else { + vaultErrors := installer.ValidateVault(vault) + errors = append(errors, vaultErrors...) + } + } + } + } + + if len(errors) > 0 { + fmt.Println("Validation failed:") + for _, err := range errors { + fmt.Printf(" - %s\n", err) + } + return fmt.Errorf("configuration validation failed with %d error(s)", len(errors)) + } + + fmt.Println("Configuration is valid!") + return nil +} + +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.K8sManaged, "k8s-managed", true, "Use Codesphere-managed Kubernetes") + c.cmd.Flags().StringSliceVar(&c.Opts.K8sControlPlane, "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.PreRun = func(cmd *cobra.Command, args []string) { + c.Generator = installer.NewConfigGenerator(c.Opts.Interactive) + } + + c.cmd.RunE = c.RunE + init.AddCommand(c.cmd) +} diff --git a/cli/cmd/init_install_config_test.go b/cli/cmd/init_install_config_test.go new file mode 100644 index 0000000..bfc8cd6 --- /dev/null +++ b/cli/cmd/init_install_config_test.go @@ -0,0 +1,303 @@ +// 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/util" +) + +var _ = Describe("ApplyProfile", func() { + DescribeTable("profile application", + func(profile string, wantErr bool, checkDatacenter string) { + c := &InitInstallConfigCmd{ + Opts: &InitInstallConfigOpts{ + Profile: profile, + }, + } + + err := c.applyProfile() + if wantErr { + Expect(err).To(HaveOccurred()) + } else { + Expect(err).NotTo(HaveOccurred()) + Expect(c.Opts.DatacenterName).To(Equal(checkDatacenter)) + } + }, + 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() { + c := &InitInstallConfigCmd{ + Opts: &InitInstallConfigOpts{ + Profile: "dev", + }, + } + + err := c.applyProfile() + Expect(err).NotTo(HaveOccurred()) + Expect(c.Opts.DatacenterID).To(Equal(1)) + Expect(c.Opts.DatacenterName).To(Equal("dev")) + Expect(c.Opts.PostgresMode).To(Equal("install")) + Expect(c.Opts.K8sManaged).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: + 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(), + } + + err = c.validateConfig() + 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(), + } + + err = c.validateConfig() + 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(), + } + + err = c.validateConfig() + Expect(err).To(HaveOccurred()) + }) + }) +}) diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 431e5cd..b3a1108 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 c1b3049..221a18f 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 @@ -28,4 +29,4 @@ like downloading new versions. * [oms-cli update](oms-cli_update.md) - Update OMS related resources * [oms-cli version](oms-cli_version.md) - Print version -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 6-Nov-2025 diff --git a/docs/oms-cli.md b/docs/oms-cli.md index c1b3049..221a18f 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 @@ -28,4 +29,4 @@ like downloading new versions. * [oms-cli update](oms-cli_update.md) - Update OMS related resources * [oms-cli version](oms-cli_version.md) - Print version -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 6-Nov-2025 diff --git a/docs/oms-cli_beta.md b/docs/oms-cli_beta.md index 1cbccfc..23da2dc 100644 --- a/docs/oms-cli_beta.md +++ b/docs/oms-cli_beta.md @@ -18,4 +18,4 @@ Be aware that that usage and behavior may change as the features are developed. * [oms-cli](oms-cli.md) - Codesphere Operations Management System (OMS) * [oms-cli beta extend](oms-cli_beta_extend.md) - Extend Codesphere ressources such as base images. -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 6-Nov-2025 diff --git a/docs/oms-cli_beta_extend.md b/docs/oms-cli_beta_extend.md index 567ebef..b9d85aa 100644 --- a/docs/oms-cli_beta_extend.md +++ b/docs/oms-cli_beta_extend.md @@ -17,4 +17,4 @@ Extend Codesphere ressources such as base images to customize them for your need * [oms-cli beta](oms-cli_beta.md) - Commands for early testing * [oms-cli beta extend baseimage](oms-cli_beta_extend_baseimage.md) - Extend Codesphere's workspace base image for customization -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 6-Nov-2025 diff --git a/docs/oms-cli_beta_extend_baseimage.md b/docs/oms-cli_beta_extend_baseimage.md index 2b2569e..b507f9e 100644 --- a/docs/oms-cli_beta_extend_baseimage.md +++ b/docs/oms-cli_beta_extend_baseimage.md @@ -28,4 +28,4 @@ oms-cli beta extend baseimage [flags] * [oms-cli beta extend](oms-cli_beta_extend.md) - Extend Codesphere ressources such as base images. -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 6-Nov-2025 diff --git a/docs/oms-cli_build.md b/docs/oms-cli_build.md index 8f20062..fbb1445 100644 --- a/docs/oms-cli_build.md +++ b/docs/oms-cli_build.md @@ -18,4 +18,4 @@ Build and push container images to a registry using the provided configuration. * [oms-cli build image](oms-cli_build_image.md) - Build and push Docker image using Dockerfile and Codesphere package version * [oms-cli build images](oms-cli_build_images.md) - Build and push container images -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 6-Nov-2025 diff --git a/docs/oms-cli_build_image.md b/docs/oms-cli_build_image.md index 6d74e8b..14c31da 100644 --- a/docs/oms-cli_build_image.md +++ b/docs/oms-cli_build_image.md @@ -32,4 +32,4 @@ $ oms-cli build image --dockerfile baseimage/Dockerfile --package codesphere-v1. * [oms-cli build](oms-cli_build.md) - Build and push images to a registry -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 6-Nov-2025 diff --git a/docs/oms-cli_build_images.md b/docs/oms-cli_build_images.md index 07ae413..6dba74d 100644 --- a/docs/oms-cli_build_images.md +++ b/docs/oms-cli_build_images.md @@ -23,4 +23,4 @@ oms-cli build images [flags] * [oms-cli build](oms-cli_build.md) - Build and push images to a registry -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 6-Nov-2025 diff --git a/docs/oms-cli_download.md b/docs/oms-cli_download.md index c34ab2e..b071f52 100644 --- a/docs/oms-cli_download.md +++ b/docs/oms-cli_download.md @@ -18,4 +18,4 @@ e.g. available Codesphere packages * [oms-cli](oms-cli.md) - Codesphere Operations Management System (OMS) * [oms-cli download package](oms-cli_download_package.md) - Download a codesphere package -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 6-Nov-2025 diff --git a/docs/oms-cli_download_package.md b/docs/oms-cli_download_package.md index b9e323b..13f6f26 100644 --- a/docs/oms-cli_download_package.md +++ b/docs/oms-cli_download_package.md @@ -36,4 +36,4 @@ $ oms-cli download package --version codesphere-v1.55.0 --file installer-lite.ta * [oms-cli download](oms-cli_download.md) - Download resources available through OMS -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 6-Nov-2025 diff --git a/docs/oms-cli_init.md b/docs/oms-cli_init.md new file mode 100644 index 0000000..cba6c7b --- /dev/null +++ b/docs/oms-cli_init.md @@ -0,0 +1,20 @@ +## 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 + +###### Auto generated by spf13/cobra on 6-Nov-2025 diff --git a/docs/oms-cli_init_install-config.md b/docs/oms-cli_init_install-config.md new file mode 100644 index 0000000..f383044 --- /dev/null +++ b/docs/oms-cli_init_install-config.md @@ -0,0 +1,67 @@ +## 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 + +###### Auto generated by spf13/cobra on 6-Nov-2025 diff --git a/docs/oms-cli_install.md b/docs/oms-cli_install.md index c3530a5..8d8c9fb 100644 --- a/docs/oms-cli_install.md +++ b/docs/oms-cli_install.md @@ -17,4 +17,4 @@ Coming soon: Install Codesphere and other components like Ceph and PostgreSQL. * [oms-cli](oms-cli.md) - Codesphere Operations Management System (OMS) * [oms-cli install codesphere](oms-cli_install_codesphere.md) - Install a Codesphere instance -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 6-Nov-2025 diff --git a/docs/oms-cli_install_codesphere.md b/docs/oms-cli_install_codesphere.md index 716f2c5..4a8bbd1 100644 --- a/docs/oms-cli_install_codesphere.md +++ b/docs/oms-cli_install_codesphere.md @@ -26,4 +26,4 @@ oms-cli install codesphere [flags] * [oms-cli install](oms-cli_install.md) - Coming soon: Install Codesphere and other components -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 6-Nov-2025 diff --git a/docs/oms-cli_licenses.md b/docs/oms-cli_licenses.md index 23d01f9..79164df 100644 --- a/docs/oms-cli_licenses.md +++ b/docs/oms-cli_licenses.md @@ -20,4 +20,4 @@ oms-cli licenses [flags] * [oms-cli](oms-cli.md) - Codesphere Operations Management System (OMS) -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 6-Nov-2025 diff --git a/docs/oms-cli_list.md b/docs/oms-cli_list.md index 3bb421d..ba7c105 100644 --- a/docs/oms-cli_list.md +++ b/docs/oms-cli_list.md @@ -19,4 +19,4 @@ eg. available Codesphere packages * [oms-cli list api-keys](oms-cli_list_api-keys.md) - List API keys * [oms-cli list packages](oms-cli_list_packages.md) - List available packages -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 6-Nov-2025 diff --git a/docs/oms-cli_list_api-keys.md b/docs/oms-cli_list_api-keys.md index 02274e0..3cb744b 100644 --- a/docs/oms-cli_list_api-keys.md +++ b/docs/oms-cli_list_api-keys.md @@ -20,4 +20,4 @@ oms-cli list api-keys [flags] * [oms-cli list](oms-cli_list.md) - List resources available through OMS -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 6-Nov-2025 diff --git a/docs/oms-cli_list_packages.md b/docs/oms-cli_list_packages.md index 9cb4e52..4823147 100644 --- a/docs/oms-cli_list_packages.md +++ b/docs/oms-cli_list_packages.md @@ -20,4 +20,4 @@ oms-cli list packages [flags] * [oms-cli list](oms-cli_list.md) - List resources available through OMS -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 6-Nov-2025 diff --git a/docs/oms-cli_register.md b/docs/oms-cli_register.md index 7e7b93c..effdc07 100644 --- a/docs/oms-cli_register.md +++ b/docs/oms-cli_register.md @@ -24,4 +24,4 @@ oms-cli register [flags] * [oms-cli](oms-cli.md) - Codesphere Operations Management System (OMS) -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 6-Nov-2025 diff --git a/docs/oms-cli_revoke.md b/docs/oms-cli_revoke.md index cac7846..2373a18 100644 --- a/docs/oms-cli_revoke.md +++ b/docs/oms-cli_revoke.md @@ -18,4 +18,4 @@ eg. api keys. * [oms-cli](oms-cli.md) - Codesphere Operations Management System (OMS) * [oms-cli revoke api-key](oms-cli_revoke_api-key.md) - Revoke an API key -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 6-Nov-2025 diff --git a/docs/oms-cli_revoke_api-key.md b/docs/oms-cli_revoke_api-key.md index a2746f8..3f07487 100644 --- a/docs/oms-cli_revoke_api-key.md +++ b/docs/oms-cli_revoke_api-key.md @@ -21,4 +21,4 @@ oms-cli revoke api-key [flags] * [oms-cli revoke](oms-cli_revoke.md) - Revoke resources available through OMS -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 6-Nov-2025 diff --git a/docs/oms-cli_update.md b/docs/oms-cli_update.md index f8b93b9..ff49320 100644 --- a/docs/oms-cli_update.md +++ b/docs/oms-cli_update.md @@ -24,4 +24,4 @@ oms-cli update [flags] * [oms-cli update oms](oms-cli_update_oms.md) - Update the OMS CLI * [oms-cli update package](oms-cli_update_package.md) - Download a codesphere package -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 6-Nov-2025 diff --git a/docs/oms-cli_update_api-key.md b/docs/oms-cli_update_api-key.md index 8d811f0..b5bc7d5 100644 --- a/docs/oms-cli_update_api-key.md +++ b/docs/oms-cli_update_api-key.md @@ -22,4 +22,4 @@ oms-cli update api-key [flags] * [oms-cli update](oms-cli_update.md) - Update OMS related resources -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 6-Nov-2025 diff --git a/docs/oms-cli_update_dockerfile.md b/docs/oms-cli_update_dockerfile.md index 5765822..9a23085 100644 --- a/docs/oms-cli_update_dockerfile.md +++ b/docs/oms-cli_update_dockerfile.md @@ -38,4 +38,4 @@ $ oms-cli update dockerfile --dockerfile baseimage/Dockerfile --package codesphe * [oms-cli update](oms-cli_update.md) - Update OMS related resources -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 6-Nov-2025 diff --git a/docs/oms-cli_update_oms.md b/docs/oms-cli_update_oms.md index 9d36592..1761a16 100644 --- a/docs/oms-cli_update_oms.md +++ b/docs/oms-cli_update_oms.md @@ -20,4 +20,4 @@ oms-cli update oms [flags] * [oms-cli update](oms-cli_update.md) - Update OMS related resources -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 6-Nov-2025 diff --git a/docs/oms-cli_update_package.md b/docs/oms-cli_update_package.md index 9d2d28b..1bc4d55 100644 --- a/docs/oms-cli_update_package.md +++ b/docs/oms-cli_update_package.md @@ -36,4 +36,4 @@ $ oms-cli download package --version codesphere-v1.55.0 --file installer-lite.ta * [oms-cli update](oms-cli_update.md) - Update OMS related resources -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 6-Nov-2025 diff --git a/docs/oms-cli_version.md b/docs/oms-cli_version.md index bea7903..5d57e94 100644 --- a/docs/oms-cli_version.md +++ b/docs/oms-cli_version.md @@ -20,4 +20,4 @@ oms-cli version [flags] * [oms-cli](oms-cli.md) - Codesphere Operations Management System (OMS) -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 6-Nov-2025 diff --git a/internal/installer/collector.go b/internal/installer/collector.go new file mode 100644 index 0000000..a02bee1 --- /dev/null +++ b/internal/installer/collector.go @@ -0,0 +1,210 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "fmt" + + "github.com/codesphere-cloud/oms/internal/installer/files" +) + +func collectField[T any](optValue T, isEmpty func(T) bool, promptFunc func() T) T { + if !isEmpty(optValue) { + return optValue + } + 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, optValue, prompt, defaultVal string) string { + return collectField(optValue, isEmptyString, func() string { + return prompter.String(prompt, defaultVal) + }) +} + +func (g *InstallConfig) collectInt(prompter *Prompter, optValue int, prompt string, defaultVal int) int { + return collectField(optValue, isEmptyInt, func() int { + return prompter.Int(prompt, defaultVal) + }) +} + +func (g *InstallConfig) collectStringSlice(prompter *Prompter, optValue []string, prompt string, defaultVal []string) []string { + return collectField(optValue, isEmptySlice, func() []string { + return prompter.StringSlice(prompt, defaultVal) + }) +} + +func (g *InstallConfig) collectChoice(prompter *Prompter, optValue, prompt string, options []string, defaultVal string) string { + return collectField(optValue, isEmptyString, func() string { + return prompter.Choice(prompt, options, defaultVal) + }) +} + +func (g *InstallConfig) collectConfig() (*files.CollectedConfig, error) { + prompter := NewPrompter(g.Interactive) + opts := g.configOpts + collected := &files.CollectedConfig{} + + // TODO: no sub functions after they are simplifies and interactive is removed and the if else are simplified + + g.collectDatacenterConfig(prompter, opts, collected) + g.collectRegistryConfig(prompter, opts, collected) + g.collectPostgresConfig(prompter, opts, collected) + g.collectCephConfig(prompter, opts, collected) + g.collectK8sConfig(prompter, opts, collected) + g.collectGatewayConfig(prompter, opts, collected) + g.collectMetalLBConfig(prompter, opts, collected) + g.collectCodesphereConfig(prompter, opts, collected) + + return collected, nil +} + +func (g *InstallConfig) collectDatacenterConfig(prompter *Prompter, opts *files.ConfigOptions, collected *files.CollectedConfig) { + fmt.Println("=== Datacenter Configuration ===") + collected.DcID = g.collectInt(prompter, opts.DatacenterID, "Datacenter ID", 1) + collected.DcName = g.collectString(prompter, opts.DatacenterName, "Datacenter name", "main") + collected.DcCity = g.collectString(prompter, opts.DatacenterCity, "Datacenter city", "Karlsruhe") + collected.DcCountry = g.collectString(prompter, opts.DatacenterCountryCode, "Country code", "DE") + collected.SecretsBaseDir = g.collectString(prompter, opts.SecretsBaseDir, "Secrets base directory", "/root/secrets") +} + +func (g *InstallConfig) collectRegistryConfig(prompter *Prompter, opts *files.ConfigOptions, collected *files.CollectedConfig) { + fmt.Println("\n=== Container Registry Configuration ===") + collected.RegistryServer = g.collectString(prompter, opts.RegistryServer, "Container registry server (e.g., ghcr.io, leave empty to skip)", "") + if collected.RegistryServer != "" { + collected.RegistryReplaceImages = opts.RegistryReplaceImages + collected.RegistryLoadContainerImgs = opts.RegistryLoadContainerImgs + if g.Interactive { + collected.RegistryReplaceImages = prompter.Bool("Replace images in BOM", true) + collected.RegistryLoadContainerImgs = prompter.Bool("Load container images from installer", false) + } + } +} + +func (g *InstallConfig) collectPostgresConfig(prompter *Prompter, opts *files.ConfigOptions, collected *files.CollectedConfig) { + fmt.Println("\n=== PostgreSQL Configuration ===") + collected.PgMode = g.collectChoice(prompter, opts.PostgresMode, "PostgreSQL setup", []string{"install", "external"}, "install") + + if collected.PgMode == "install" { + collected.PgPrimaryIP = g.collectString(prompter, opts.PostgresPrimaryIP, "Primary PostgreSQL server IP", "10.50.0.2") + collected.PgPrimaryHost = g.collectString(prompter, opts.PostgresPrimaryHost, "Primary PostgreSQL hostname", "pg-primary-node") + + if g.Interactive { + hasReplica := prompter.Bool("Configure PostgreSQL replica", true) + if hasReplica { + collected.PgReplicaIP = g.collectString(prompter, opts.PostgresReplicaIP, "Replica PostgreSQL server IP", "10.50.0.3") + collected.PgReplicaName = g.collectString(prompter, opts.PostgresReplicaName, "Replica name (lowercase alphanumeric + underscore only)", "replica1") + } + } else { + collected.PgReplicaIP = opts.PostgresReplicaIP + collected.PgReplicaName = opts.PostgresReplicaName + } + } else { + collected.PgExternal = g.collectString(prompter, opts.PostgresExternal, "External PostgreSQL server address", "postgres.example.com:5432") + } +} + +func (g *InstallConfig) collectCephConfig(prompter *Prompter, opts *files.ConfigOptions, collected *files.CollectedConfig) { + fmt.Println("\n=== Ceph Configuration ===") + collected.CephSubnet = g.collectString(prompter, opts.CephSubnet, "Ceph nodes subnet (CIDR)", "10.53.101.0/24") + + if len(opts.CephHosts) == 0 { + numHosts := prompter.Int("Number of Ceph hosts", 3) + collected.CephHosts = make([]files.CephHost, numHosts) + for i := 0; i < numHosts; i++ { + fmt.Printf("\nCeph Host %d:\n", i+1) + collected.CephHosts[i].Hostname = prompter.String(" Hostname (as shown by 'hostname' command)", fmt.Sprintf("ceph-node-%d", i)) + collected.CephHosts[i].IPAddress = prompter.String(" IP address", fmt.Sprintf("10.53.101.%d", i+2)) + collected.CephHosts[i].IsMaster = (i == 0) + } + } else { + collected.CephHosts = make([]files.CephHost, len(opts.CephHosts)) + for i, host := range opts.CephHosts { + collected.CephHosts[i] = files.CephHost(host) + } + } +} + +func (g *InstallConfig) collectK8sConfig(prompter *Prompter, opts *files.ConfigOptions, collected *files.CollectedConfig) { + fmt.Println("\n=== Kubernetes Configuration ===") + collected.K8sManaged = opts.K8sManaged + if g.Interactive { + collected.K8sManaged = prompter.Bool("Use Codesphere-managed Kubernetes (k0s)", true) + } + + if collected.K8sManaged { + collected.K8sAPIServer = g.collectString(prompter, opts.K8sAPIServer, "Kubernetes API server host (LB/DNS/IP)", "10.50.0.2") + collected.K8sControlPlane = g.collectStringSlice(prompter, opts.K8sControlPlane, "Control plane IP addresses (comma-separated)", []string{"10.50.0.2"}) + collected.K8sWorkers = g.collectStringSlice(prompter, opts.K8sWorkers, "Worker node IP addresses (comma-separated)", []string{"10.50.0.2", "10.50.0.3", "10.50.0.4"}) + } else { + collected.K8sPodCIDR = g.collectString(prompter, opts.K8sPodCIDR, "Pod CIDR of external cluster", "100.96.0.0/11") + collected.K8sServiceCIDR = g.collectString(prompter, opts.K8sServiceCIDR, "Service CIDR of external cluster", "100.64.0.0/13") + fmt.Println("Note: You'll need to provide kubeconfig in the vault file for external Kubernetes") + } +} + +func (g *InstallConfig) collectGatewayConfig(prompter *Prompter, opts *files.ConfigOptions, collected *files.CollectedConfig) { + // TODO: in ifs + fmt.Println("\n=== Cluster Gateway Configuration ===") + collected.GatewayType = g.collectChoice(prompter, opts.ClusterGatewayType, "Gateway service type", []string{"LoadBalancer", "ExternalIP"}, "LoadBalancer") + if collected.GatewayType == "ExternalIP" { + collected.GatewayIPs = g.collectStringSlice(prompter, opts.ClusterGatewayIPs, "Gateway IP addresses (comma-separated)", []string{"10.51.0.2", "10.51.0.3"}) + } else { + collected.GatewayIPs = opts.ClusterGatewayIPs + } + + collected.PublicGatewayType = g.collectChoice(prompter, opts.ClusterPublicGatewayType, "Public gateway service type", []string{"LoadBalancer", "ExternalIP"}, "LoadBalancer") + if collected.PublicGatewayType == "ExternalIP" { + collected.PublicGatewayIPs = g.collectStringSlice(prompter, opts.ClusterPublicGatewayIPs, "Public gateway IP addresses (comma-separated)", []string{"10.52.0.2", "10.52.0.3"}) + } else { + collected.PublicGatewayIPs = opts.ClusterPublicGatewayIPs + } +} + +func (g *InstallConfig) collectMetalLBConfig(prompter *Prompter, opts *files.ConfigOptions, collected *files.CollectedConfig) { + fmt.Println("\n=== MetalLB Configuration (Optional) ===") + if g.Interactive { + collected.MetalLBEnabled = prompter.Bool("Enable MetalLB", false) + if collected.MetalLBEnabled { + numPools := prompter.Int("Number of MetalLB IP pools", 1) + collected.MetalLBPools = make([]files.MetalLBPoolDef, numPools) + for i := 0; i < numPools; i++ { + fmt.Printf("\nMetalLB Pool %d:\n", i+1) + poolName := prompter.String(" Pool name", fmt.Sprintf("pool-%d", i+1)) + poolIPs := prompter.StringSlice(" IP addresses/ranges (comma-separated)", []string{"10.10.10.100-10.10.10.200"}) + collected.MetalLBPools[i] = files.MetalLBPoolDef{ + Name: poolName, + IPAddresses: poolIPs, + } + } + } + } else if opts.MetalLBEnabled { + collected.MetalLBEnabled = true + collected.MetalLBPools = make([]files.MetalLBPoolDef, len(opts.MetalLBPools)) + for i, pool := range opts.MetalLBPools { + collected.MetalLBPools[i] = files.MetalLBPoolDef(pool) + } + } +} + +func (g *InstallConfig) collectCodesphereConfig(prompter *Prompter, opts *files.ConfigOptions, collected *files.CollectedConfig) { + fmt.Println("\n=== Codesphere Application Configuration ===") + collected.CodesphereDomain = g.collectString(prompter, opts.CodesphereDomain, "Main Codesphere domain", "codesphere.yourcompany.com") + collected.WorkspaceDomain = g.collectString(prompter, opts.CodesphereWorkspaceBaseDomain, "Workspace base domain (*.domain should point to public gateway)", "ws.yourcompany.com") + collected.PublicIP = g.collectString(prompter, opts.CodespherePublicIP, "Primary public IP for workspaces", "") + collected.CustomDomain = g.collectString(prompter, opts.CodesphereCustomDomainBaseDomain, "Custom domain CNAME base", "custom.yourcompany.com") + collected.DnsServers = g.collectStringSlice(prompter, opts.CodesphereDNSServers, "DNS servers (comma-separated)", []string{"1.1.1.1", "8.8.8.8"}) + + fmt.Println("\n=== Workspace Plans Configuration ===") + collected.WorkspaceImageBomRef = g.collectString(prompter, opts.CodesphereWorkspaceImageBomRef, "Workspace agent image BOM reference", "workspace-agent-24.04") + collected.HostingPlanCPU = g.collectInt(prompter, opts.CodesphereHostingPlanCPU, "Hosting plan CPU (tenths, e.g., 10 = 1 core)", 10) + collected.HostingPlanMemory = g.collectInt(prompter, opts.CodesphereHostingPlanMemory, "Hosting plan memory (MB)", 2048) + collected.HostingPlanStorage = g.collectInt(prompter, opts.CodesphereHostingPlanStorage, "Hosting plan storage (MB)", 20480) + collected.HostingPlanTempStorage = g.collectInt(prompter, opts.CodesphereHostingPlanTempStorage, "Hosting plan temp storage (MB)", 1024) + collected.WorkspacePlanName = g.collectString(prompter, opts.CodesphereWorkspacePlanName, "Workspace plan name", "Standard Developer") + collected.WorkspacePlanMaxReplica = g.collectInt(prompter, opts.CodesphereWorkspacePlanMaxReplica, "Max replicas per workspace", 3) +} diff --git a/internal/installer/config_manager.go b/internal/installer/config_manager.go new file mode 100644 index 0000000..8d46cdc --- /dev/null +++ b/internal/installer/config_manager.go @@ -0,0 +1,366 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "fmt" + "net" + + "github.com/codesphere-cloud/oms/internal/installer/files" + "github.com/codesphere-cloud/oms/internal/util" + "gopkg.in/yaml.v3" +) + +type InstallConfigManager interface { + CollectConfiguration(opts *files.ConfigOptions) (*files.RootConfig, error) + WriteConfigAndVault(configPath, vaultPath string, withComments bool) error +} + +type InstallConfig struct { + Interactive bool + configOpts *files.ConfigOptions + config *files.RootConfig + fileIO util.FileIO +} + +func NewConfigGenerator(interactive bool) InstallConfigManager { + return &InstallConfig{ + Interactive: interactive, + fileIO: &util.FilesystemWriter{}, + } +} + +func (g *InstallConfig) CollectConfiguration(opts *files.ConfigOptions) (*files.RootConfig, error) { + g.configOpts = opts + + collectedOpts, err := g.collectConfig() + if err != nil { + return nil, fmt.Errorf("failed to collect configuration: %w", err) + } + + config, err := g.convertConfig(collectedOpts) + if err != nil { + return nil, fmt.Errorf("failed to convert configuration: %w", err) + } + + if err := g.generateSecrets(config); err != nil { + return nil, fmt.Errorf("failed to generate secrets: %w", err) + } + + g.config = config + + return config, nil +} + +func (g *InstallConfig) convertConfig(collected *files.CollectedConfig) (*files.RootConfig, error) { + config := &files.RootConfig{ + DataCenter: files.DataCenterConfig{ + ID: collected.DcID, + Name: collected.DcName, + City: collected.DcCity, + CountryCode: collected.DcCountry, + }, + Secrets: files.SecretsConfig{ + BaseDir: collected.SecretsBaseDir, + }, + } + + if collected.RegistryServer != "" { + config.Registry = files.RegistryConfig{ + Server: collected.RegistryServer, + ReplaceImagesInBom: collected.RegistryReplaceImages, + LoadContainerImages: collected.RegistryLoadContainerImgs, + } + } + + if collected.PgMode == "install" { + config.Postgres = files.PostgresConfig{ + Primary: &files.PostgresPrimaryConfig{ + IP: collected.PgPrimaryIP, + Hostname: collected.PgPrimaryHost, + }, + } + + if collected.PgReplicaIP != "" { + config.Postgres.Replica = &files.PostgresReplicaConfig{ + IP: collected.PgReplicaIP, + Name: collected.PgReplicaName, + } + } + } else { + config.Postgres = files.PostgresConfig{ + ServerAddress: collected.PgExternal, + } + } + + config.Ceph = files.CephConfig{ + NodesSubnet: collected.CephSubnet, + Hosts: collected.CephHosts, + 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, + }, + }, + }, + } + + config.Kubernetes = files.KubernetesConfig{ + ManagedByCodesphere: collected.K8sManaged, + } + + if collected.K8sManaged { + config.Kubernetes.APIServerHost = collected.K8sAPIServer + config.Kubernetes.ControlPlanes = make([]files.K8sNode, len(collected.K8sControlPlane)) + for i, ip := range collected.K8sControlPlane { + config.Kubernetes.ControlPlanes[i] = files.K8sNode{IPAddress: ip} + } + config.Kubernetes.Workers = make([]files.K8sNode, len(collected.K8sWorkers)) + for i, ip := range collected.K8sWorkers { + config.Kubernetes.Workers[i] = files.K8sNode{IPAddress: ip} + } + config.Kubernetes.NeedsKubeConfig = false + } else { + config.Kubernetes.PodCIDR = collected.K8sPodCIDR + config.Kubernetes.ServiceCIDR = collected.K8sServiceCIDR + config.Kubernetes.NeedsKubeConfig = true + } + + config.Cluster = files.ClusterConfig{ + Certificates: files.ClusterCertificates{ + CA: files.CAConfig{ + Algorithm: "RSA", + KeySizeBits: 2048, + }, + }, + Gateway: files.GatewayConfig{ + ServiceType: collected.GatewayType, + IPAddresses: collected.GatewayIPs, + }, + PublicGateway: files.GatewayConfig{ + ServiceType: collected.PublicGatewayType, + IPAddresses: collected.PublicGatewayIPs, + }, + } + + if collected.MetalLBEnabled { + config.MetalLB = &files.MetalLBConfig{ + Enabled: true, + Pools: collected.MetalLBPools, + } + } + + config.Codesphere = files.CodesphereConfig{ + Domain: collected.CodesphereDomain, + WorkspaceHostingBaseDomain: collected.WorkspaceDomain, + PublicIP: collected.PublicIP, + CustomDomains: files.CustomDomainsConfig{ + CNameBaseDomain: collected.CustomDomain, + }, + DNSServers: collected.DnsServers, + Experiments: []string{}, + 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: collected.WorkspaceImageBomRef, + }, + Pool: map[int]int{1: 1}, + }, + }, + }, + }, + }, + Plans: files.PlansConfig{ + HostingPlans: map[int]files.HostingPlan{ + 1: { + CPUTenth: collected.HostingPlanCPU, + GPUParts: 0, + MemoryMb: collected.HostingPlanMemory, + StorageMb: collected.HostingPlanStorage, + TempStorageMb: collected.HostingPlanTempStorage, + }, + }, + WorkspacePlans: map[int]files.WorkspacePlan{ + 1: { + Name: collected.WorkspacePlanName, + HostingPlanID: 1, + MaxReplicas: collected.WorkspacePlanMaxReplica, + OnDemand: true, + }, + }, + }, + } + + config.ManagedServiceBackends = &files.ManagedServiceBackendsConfig{ + Postgres: make(map[string]interface{}), + } + + return config, nil +} + +func (g *InstallConfig) WriteConfigAndVault(configPath, vaultPath string, withComments bool) error { + if g.config == nil { + return fmt.Errorf("no configuration collected - call CollectConfiguration first") + } + + configYAML, err := MarshalConfig(g.config) + 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 + } + + vault := g.config.ExtractVault() + vaultYAML, err := MarshalVault(vault) + 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...) +} + +func ValidateConfig(config *files.RootConfig) []string { + errors := []string{} + + if config.DataCenter.ID == 0 { + errors = append(errors, "datacenter ID is required") + } + if config.DataCenter.Name == "" { + errors = append(errors, "datacenter name is required") + } + + if len(config.Ceph.Hosts) == 0 { + errors = append(errors, "at least one Ceph host is required") + } + for _, host := range config.Ceph.Hosts { + if !IsValidIP(host.IPAddress) { + errors = append(errors, fmt.Sprintf("invalid Ceph host IP: %s", host.IPAddress)) + } + } + + if config.Kubernetes.ManagedByCodesphere { + if len(config.Kubernetes.ControlPlanes) == 0 { + errors = append(errors, "at least one K8s control plane node is required") + } + } else { + if config.Kubernetes.PodCIDR == "" { + errors = append(errors, "pod CIDR is required for external Kubernetes") + } + if config.Kubernetes.ServiceCIDR == "" { + errors = append(errors, "service CIDR is required for external Kubernetes") + } + } + + if config.Codesphere.Domain == "" { + errors = append(errors, "Codesphere domain is required") + } + + return errors +} + +func ValidateVault(vault *files.InstallVault) []string { + errors := []string{} + requiredSecrets := []string{"cephSshPrivateKey", "selfSignedCaKeyPem", "domainAuthPrivateKey", "domainAuthPublicKey"} + foundSecrets := make(map[string]bool) + + for _, secret := range 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 IsValidIP(ip string) bool { + return net.ParseIP(ip) != nil +} + +func MarshalConfig(config *files.RootConfig) ([]byte, error) { + return yaml.Marshal(config) +} + +func MarshalVault(vault *files.InstallVault) ([]byte, error) { + return yaml.Marshal(vault) +} + +func UnmarshalConfig(data []byte) (*files.RootConfig, error) { + var config files.RootConfig + err := yaml.Unmarshal(data, &config) + return &config, err +} + +func UnmarshalVault(data []byte) (*files.InstallVault, error) { + var vault files.InstallVault + err := yaml.Unmarshal(data, &vault) + return &vault, err +} diff --git a/internal/installer/crypto.go b/internal/installer/crypto.go new file mode 100644 index 0000000..0fbe829 --- /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 0000000..faeaa69 --- /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 d31eab6..09f53c0 100644 --- a/internal/installer/files/config_yaml.go +++ b/internal/installer/files/config_yaml.go @@ -6,22 +6,224 @@ package files import ( "fmt" "os" + "strings" "gopkg.in/yaml.v3" ) // 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 { + 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,6 +246,245 @@ type ImageRef struct { Dockerfile string `yaml:"dockerfile"` } +type PlansConfig struct { + HostingPlans map[int]HostingPlan `yaml:"hostingPlans"` + WorkspacePlans map[int]WorkspacePlan `yaml:"workspacePlans"` +} + +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"` +} + +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 InstallVault struct { + Secrets []SecretEntry `yaml:"secrets"` +} + +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"` +} + +type ConfigOptions struct { + DatacenterID int + DatacenterName string + DatacenterCity string + DatacenterCountryCode string + + RegistryServer string + RegistryReplaceImages bool + RegistryLoadContainerImgs bool + + PostgresMode string + PostgresPrimaryIP string + PostgresPrimaryHost string + PostgresReplicaIP string + PostgresReplicaName string + PostgresExternal string + + CephSubnet string + CephHosts []CephHostConfig + + K8sManaged bool + K8sAPIServer string + K8sControlPlane []string + K8sWorkers []string + K8sExternalHost string + K8sPodCIDR string + K8sServiceCIDR string + + ClusterGatewayType string + ClusterGatewayIPs []string + ClusterPublicGatewayType string + ClusterPublicGatewayIPs []string + + MetalLBEnabled bool + MetalLBPools []MetalLBPool + + CodesphereDomain string + CodespherePublicIP string + CodesphereWorkspaceBaseDomain string + CodesphereCustomDomainBaseDomain string + CodesphereDNSServers []string + CodesphereWorkspaceImageBomRef string + CodesphereHostingPlanCPU int + CodesphereHostingPlanMemory int + CodesphereHostingPlanStorage int + CodesphereHostingPlanTempStorage int + CodesphereWorkspacePlanName string + CodesphereWorkspacePlanMaxReplica int + + SecretsBaseDir string +} + +type CephHostConfig struct { + Hostname string + IPAddress string + IsMaster bool +} + +type MetalLBPool struct { + Name string + IPAddresses []string +} + +type CollectedConfig struct { + // Datacenter + DcID int + DcName string + DcCity string + DcCountry string + SecretsBaseDir string + + // Registry + RegistryServer string + RegistryReplaceImages bool + RegistryLoadContainerImgs bool + + // PostgreSQL + PgMode string + PgPrimaryIP string + PgPrimaryHost string + PgReplicaIP string + PgReplicaName string + PgExternal string + + // Ceph + CephSubnet string + CephHosts []CephHost + + // Kubernetes + K8sManaged bool + K8sAPIServer string + K8sControlPlane []string + K8sWorkers []string + K8sPodCIDR string + K8sServiceCIDR string + + // Cluster Gateway + GatewayType string + GatewayIPs []string + PublicGatewayType string + PublicGatewayIPs []string + + // MetalLB + MetalLBEnabled bool + MetalLBPools []MetalLBPoolDef + + // Codesphere + CodesphereDomain string + WorkspaceDomain string + PublicIP string + CustomDomain string + DnsServers []string + WorkspaceImageBomRef string + HostingPlanCPU int + HostingPlanMemory int + HostingPlanStorage int + HostingPlanTempStorage int + WorkspacePlanName string + WorkspacePlanMaxReplica int +} + func (c *RootConfig) ParseConfig(filePath string) error { configData, err := os.ReadFile(filePath) if err != nil { @@ -82,3 +523,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/mocks.go b/internal/installer/mocks.go index 9779117..3e96b7e 100644 --- a/internal/installer/mocks.go +++ b/internal/installer/mocks.go @@ -91,6 +91,136 @@ 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} +} + +// CollectConfiguration provides a mock function for the type MockInstallConfigManager +func (_mock *MockInstallConfigManager) CollectConfiguration(opts *files.ConfigOptions) (*files.RootConfig, error) { + ret := _mock.Called(opts) + + if len(ret) == 0 { + panic("no return value specified for CollectConfiguration") + } + + var r0 *files.RootConfig + var r1 error + if returnFunc, ok := ret.Get(0).(func(*files.ConfigOptions) (*files.RootConfig, error)); ok { + return returnFunc(opts) + } + if returnFunc, ok := ret.Get(0).(func(*files.ConfigOptions) *files.RootConfig); ok { + r0 = returnFunc(opts) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*files.RootConfig) + } + } + if returnFunc, ok := ret.Get(1).(func(*files.ConfigOptions) error); ok { + r1 = returnFunc(opts) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockInstallConfigManager_CollectConfiguration_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CollectConfiguration' +type MockInstallConfigManager_CollectConfiguration_Call struct { + *mock.Call +} + +// CollectConfiguration is a helper method to define mock.On call +// - opts +func (_e *MockInstallConfigManager_Expecter) CollectConfiguration(opts interface{}) *MockInstallConfigManager_CollectConfiguration_Call { + return &MockInstallConfigManager_CollectConfiguration_Call{Call: _e.mock.On("CollectConfiguration", opts)} +} + +func (_c *MockInstallConfigManager_CollectConfiguration_Call) Run(run func(opts *files.ConfigOptions)) *MockInstallConfigManager_CollectConfiguration_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(*files.ConfigOptions)) + }) + return _c +} + +func (_c *MockInstallConfigManager_CollectConfiguration_Call) Return(rootConfig *files.RootConfig, err error) *MockInstallConfigManager_CollectConfiguration_Call { + _c.Call.Return(rootConfig, err) + return _c +} + +func (_c *MockInstallConfigManager_CollectConfiguration_Call) RunAndReturn(run func(opts *files.ConfigOptions) (*files.RootConfig, error)) *MockInstallConfigManager_CollectConfiguration_Call { + _c.Call.Return(run) + return _c +} + +// WriteConfigAndVault provides a mock function for the type MockInstallConfigManager +func (_mock *MockInstallConfigManager) WriteConfigAndVault(configPath string, vaultPath string, withComments bool) error { + ret := _mock.Called(configPath, vaultPath, withComments) + + if len(ret) == 0 { + panic("no return value specified for WriteConfigAndVault") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string, string, bool) error); ok { + r0 = returnFunc(configPath, vaultPath, withComments) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockInstallConfigManager_WriteConfigAndVault_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WriteConfigAndVault' +type MockInstallConfigManager_WriteConfigAndVault_Call struct { + *mock.Call +} + +// WriteConfigAndVault is a helper method to define mock.On call +// - configPath +// - vaultPath +// - withComments +func (_e *MockInstallConfigManager_Expecter) WriteConfigAndVault(configPath interface{}, vaultPath interface{}, withComments interface{}) *MockInstallConfigManager_WriteConfigAndVault_Call { + return &MockInstallConfigManager_WriteConfigAndVault_Call{Call: _e.mock.On("WriteConfigAndVault", configPath, vaultPath, withComments)} +} + +func (_c *MockInstallConfigManager_WriteConfigAndVault_Call) Run(run func(configPath string, vaultPath string, withComments bool)) *MockInstallConfigManager_WriteConfigAndVault_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(string), args[2].(bool)) + }) + return _c +} + +func (_c *MockInstallConfigManager_WriteConfigAndVault_Call) Return(err error) *MockInstallConfigManager_WriteConfigAndVault_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockInstallConfigManager_WriteConfigAndVault_Call) RunAndReturn(run func(configPath string, vaultPath string, withComments bool) error) *MockInstallConfigManager_WriteConfigAndVault_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 0000000..91d4410 --- /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 0000000..6b995cb --- /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.go b/internal/installer/secrets.go new file mode 100644 index 0000000..a4f225d --- /dev/null +++ b/internal/installer/secrets.go @@ -0,0 +1,92 @@ +// 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(config *files.RootConfig) error { + fmt.Println("Generating domain authentication keys...") + domainAuthPub, domainAuthPriv, err := GenerateECDSAKeyPair() + if err != nil { + return fmt.Errorf("failed to generate domain auth keys: %w", err) + } + config.Codesphere.DomainAuthPublicKey = domainAuthPub + config.Codesphere.DomainAuthPrivateKey = domainAuthPriv + + fmt.Println("Generating ingress CA certificate...") + ingressCAKey, ingressCACert, err := GenerateCA("Cluster Ingress CA", "DE", "Karlsruhe", "Codesphere") + if err != nil { + return fmt.Errorf("failed to generate ingress CA: %w", err) + } + config.Cluster.Certificates.CA.CertPem = ingressCACert + config.Cluster.IngressCAKey = ingressCAKey + + fmt.Println("Generating Ceph SSH keys...") + cephSSHPub, cephSSHPriv, err := GenerateSSHKeyPair() + if err != nil { + return fmt.Errorf("failed to generate Ceph SSH keys: %w", err) + } + config.Ceph.CephAdmSSHKey.PublicKey = cephSSHPub + config.Ceph.SshPrivateKey = cephSSHPriv + + if config.Postgres.Primary != nil { + if err := g.generatePostgresSecrets(config); err != nil { + return err + } + } + + return nil +} + +func (g *InstallConfig) generatePostgresSecrets(config *files.RootConfig) error { + fmt.Println("Generating PostgreSQL certificates and passwords...") + + pgCAKey, pgCACert, err := GenerateCA("PostgreSQL CA", "DE", "Karlsruhe", "Codesphere") + if err != nil { + return fmt.Errorf("failed to generate PostgreSQL CA: %w", err) + } + config.Postgres.CACertPem = pgCACert + config.Postgres.CaCertPrivateKey = pgCAKey + + pgPrimaryKey, pgPrimaryCert, err := GenerateServerCertificate( + pgCAKey, + pgCACert, + 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.Primary.SSLConfig.ServerCertPem = pgPrimaryCert + config.Postgres.Primary.PrivateKey = pgPrimaryKey + + config.Postgres.AdminPassword = GeneratePassword(32) + config.Postgres.ReplicaPassword = GeneratePassword(32) + + if config.Postgres.Replica != nil { + pgReplicaKey, pgReplicaCert, err := GenerateServerCertificate( + pgCAKey, + pgCACert, + config.Postgres.Replica.Name, + []string{config.Postgres.Replica.IP}, + ) + if err != nil { + return fmt.Errorf("failed to generate replica PostgreSQL certificate: %w", err) + } + config.Postgres.Replica.SSLConfig.ServerCertPem = pgReplicaCert + config.Postgres.Replica.PrivateKey = pgReplicaKey + } + + 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/secrets_test.go b/internal/installer/secrets_test.go new file mode 100644 index 0000000..14fbd4b --- /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 0000000..d385f2c --- /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/util/filewriter.go b/internal/util/filewriter.go index 9cca2f4..25c7071 100644 --- a/internal/util/filewriter.go +++ b/internal/util/filewriter.go @@ -4,6 +4,7 @@ package util import ( + "fmt" "os" ) @@ -16,6 +17,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{} @@ -28,6 +30,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 69eedc2..1e85d83 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)