From e2dc14e8470e7cebca71ed46783e893b63155978 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Thu, 30 Oct 2025 13:26:59 +0100 Subject: [PATCH 01/24] feat: Add initialization commands and configuration builders for Codesphere installer --- cli/cmd/init.go | 25 + cli/cmd/init_install_config.go | 779 ++++++++++++++++++++++++ cli/cmd/init_install_config_builders.go | 647 ++++++++++++++++++++ cli/cmd/init_install_config_gen0.go | 319 ++++++++++ cli/cmd/root.go | 1 + 5 files changed, 1771 insertions(+) create mode 100644 cli/cmd/init.go create mode 100644 cli/cmd/init_install_config.go create mode 100644 cli/cmd/init_install_config_builders.go create mode 100644 cli/cmd/init_install_config_gen0.go diff --git a/cli/cmd/init.go b/cli/cmd/init.go new file mode 100644 index 00000000..f4f383c6 --- /dev/null +++ b/cli/cmd/init.go @@ -0,0 +1,25 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "github.com/codesphere-cloud/cs-go/pkg/io" + "github.com/spf13/cobra" +) + +type InitCmd struct { + cmd *cobra.Command +} + +func AddInitCmd(rootCmd *cobra.Command, opts *GlobalOptions) { + init := InitCmd{ + cmd: &cobra.Command{ + Use: "init", + Short: "Initialize configuration files", + Long: io.Long(`Initialize configuration files for Codesphere installation and other components.`), + }, + } + rootCmd.AddCommand(init.cmd) + AddInitInstallConfigCmd(init.cmd, opts) +} diff --git a/cli/cmd/init_install_config.go b/cli/cmd/init_install_config.go new file mode 100644 index 00000000..0e55f392 --- /dev/null +++ b/cli/cmd/init_install_config.go @@ -0,0 +1,779 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + "net" + "strings" + + "github.com/codesphere-cloud/cs-go/pkg/io" + "github.com/codesphere-cloud/oms/internal/util" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +type InitInstallConfigCmd struct { + cmd *cobra.Command + Opts *InitInstallConfigOpts + FileWriter util.FileIO +} + +type InitInstallConfigOpts struct { + *GlobalOptions + + ConfigFile string + VaultFile string + + Profile string + ValidateOnly bool + WithComments bool + NonInteractive 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 []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 +} + +type CephHostConfig struct { + Hostname string + IPAddress string + IsMaster bool +} + +type MetalLBPool struct { + Name string + IPAddresses []string +} + +type Gen0Config struct { + 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"` + 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"` +} + +type PostgresPrimaryConfig struct { + SSLConfig SSLConfig `yaml:"sslConfig"` + IP string `yaml:"ip"` + Hostname string `yaml:"hostname"` +} + +type PostgresReplicaConfig struct { + IP string `yaml:"ip"` + Name string `yaml:"name"` + SSLConfig SSLConfig `yaml:"sslConfig"` +} + +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"` +} + +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"` +} + +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"` +} + +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 { + 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"` +} + +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 ImageRef struct { + BomRef string `yaml:"bomRef"` +} + +type DeployConfig struct { + Images map[string]DeployImage `yaml:"images"` +} + +type DeployImage struct { + Name string `yaml:"name"` + SupportedUntil string `yaml:"supportedUntil"` + Flavors map[string]DeployFlavor `yaml:"flavors"` +} + +type DeployFlavor struct { + Image ImageRef `yaml:"image"` + Pool map[int]int `yaml:"pool"` +} + +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 Gen0Vault 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"` +} + +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() + + if err := c.collectConfiguration(); err != nil { + return fmt.Errorf("failed to collect configuration: %w", err) + } + + var generatedSecrets *GeneratedSecrets + if c.Opts.GenerateKeys { + fmt.Println("\nGenerating SSH keys and certificates...") + var err error + generatedSecrets, err = c.generateSecrets() + if err != nil { + return fmt.Errorf("failed to generate secrets: %w", err) + } + fmt.Println("Keys and certificates generated successfully") + } + + config := c.buildGen0Config(generatedSecrets) + configYAML, err := yaml.Marshal(config) + if err != nil { + return fmt.Errorf("failed to marshal config.yaml: %w", err) + } + + if c.Opts.WithComments { + configYAML = c.addConfigComments(configYAML) + } + + configFile, err := c.FileWriter.Create(c.Opts.ConfigFile) + if err != nil { + return fmt.Errorf("failed to create config file: %w", err) + } + defer util.CloseFileIgnoreError(configFile) + + if _, err = configFile.Write(configYAML); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + + fmt.Printf("\nConfiguration file created: %s\n", c.Opts.ConfigFile) + + vault := c.buildGen0Vault(generatedSecrets) + vaultYAML, err := yaml.Marshal(vault) + if err != nil { + return fmt.Errorf("failed to marshal vault.yaml: %w", err) + } + + if c.Opts.WithComments { + vaultYAML = c.addVaultComments(vaultYAML) + } + + vaultFile, err := c.FileWriter.Create(c.Opts.VaultFile) + if err != nil { + return fmt.Errorf("failed to create vault file: %w", err) + } + defer util.CloseFileIgnoreError(vaultFile) + + if _, err = vaultFile.Write(vaultYAML); err != nil { + return fmt.Errorf("failed to write vault file: %w", err) + } + + fmt.Printf("Secrets file created: %s\n", c.Opts.VaultFile) + + fmt.Println("\n" + strings.Repeat("=", 70)) + fmt.Println("Configuration files successfully generated!") + fmt.Println(strings.Repeat("=", 70)) + + if c.Opts.GenerateKeys { + 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 Gen0 installer with these configuration files") + fmt.Println() + + return nil +} + +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 = []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.NonInteractive = true + 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 = []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 = []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.NonInteractive = true + 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) + + var config Gen0Config + decoder := yaml.NewDecoder(configFile) + if err := decoder.Decode(&config); err != nil { + return fmt.Errorf("failed to parse config.yaml: %w", err) + } + + 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") + } + + 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) + + var vault Gen0Vault + vaultDecoder := yaml.NewDecoder(vaultFile) + if err := vaultDecoder.Decode(&vault); err != nil { + errors = append(errors, fmt.Sprintf("failed to parse vault.yaml: %v", err)) + } else { + 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)) + } + } + } + } + } + + 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 isValidIP(ip string) bool { + return net.ParseIP(ip) != nil +} + +func AddInitInstallConfigCmd(init *cobra.Command, opts *GlobalOptions) { + c := InitInstallConfigCmd{ + cmd: &cobra.Command{ + Use: "install-config", + Short: "Initialize Codesphere Gen0 installer configuration files", + Long: io.Long(`Initialize config.yaml and prod.vault.yaml for the Codesphere Gen0 installer. + + This command generates two files: + - config.yaml: Main configuration (infrastructure, networking, plans) + - prod.vault.yaml: Secrets file (keys, certificates, passwords) + + 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", []io.Example{ + {Cmd: "-c config.yaml -v prod.vault.yaml", Desc: "Create Gen0 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.NonInteractive, "non-interactive", false, "Use default values without prompting") + 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.RunE = c.RunE + init.AddCommand(c.cmd) +} diff --git a/cli/cmd/init_install_config_builders.go b/cli/cmd/init_install_config_builders.go new file mode 100644 index 00000000..e775cc1d --- /dev/null +++ b/cli/cmd/init_install_config_builders.go @@ -0,0 +1,647 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "bufio" + "fmt" + "os" + "strconv" + "strings" +) + +func (c *InitInstallConfigCmd) collectConfiguration() error { + fmt.Println("=== Datacenter Configuration ===") + if c.Opts.DatacenterID == 0 { + c.Opts.DatacenterID = c.promptInt("Datacenter ID", 1) + } + if c.Opts.DatacenterName == "" { + c.Opts.DatacenterName = c.promptString("Datacenter name", "main") + } + if c.Opts.DatacenterCity == "" { + c.Opts.DatacenterCity = c.promptString("Datacenter city", "Karlsruhe") + } + if c.Opts.DatacenterCountryCode == "" { + c.Opts.DatacenterCountryCode = c.promptString("Country code", "DE") + } + + if c.Opts.SecretsBaseDir == "" { + c.Opts.SecretsBaseDir = c.promptString("Secrets base directory", "/root/secrets") + } + + fmt.Println("\n=== Container Registry Configuration ===") + if c.Opts.RegistryServer == "" { + c.Opts.RegistryServer = c.promptString("Container registry server (e.g., ghcr.io, leave empty to skip)", "") + } + if c.Opts.RegistryServer != "" { + if !c.Opts.NonInteractive { + c.Opts.RegistryReplaceImages = c.promptBool("Replace images in BOM", true) + c.Opts.RegistryLoadContainerImgs = c.promptBool("Load container images from installer", false) + } + } + + fmt.Println("\n=== PostgreSQL Configuration ===") + if c.Opts.PostgresMode == "" { + c.Opts.PostgresMode = c.promptChoice("PostgreSQL setup", []string{"install", "external"}, "install") + } + + if c.Opts.PostgresMode == "install" { + if c.Opts.PostgresPrimaryIP == "" { + c.Opts.PostgresPrimaryIP = c.promptString("Primary PostgreSQL server IP", "10.50.0.2") + } + if c.Opts.PostgresPrimaryHost == "" { + c.Opts.PostgresPrimaryHost = c.promptString("Primary PostgreSQL hostname", "pg-primary-node") + } + if !c.Opts.NonInteractive { + hasReplica := c.promptBool("Configure PostgreSQL replica", true) + if hasReplica { + if c.Opts.PostgresReplicaIP == "" { + c.Opts.PostgresReplicaIP = c.promptString("Replica PostgreSQL server IP", "10.50.0.3") + } + if c.Opts.PostgresReplicaName == "" { + c.Opts.PostgresReplicaName = c.promptString("Replica name (lowercase alphanumeric + underscore only)", "replica1") + } + } + } + c.Opts.GenerateKeys = true + } else { + if c.Opts.PostgresExternal == "" { + c.Opts.PostgresExternal = c.promptString("External PostgreSQL server address", "postgres.example.com:5432") + } + } + + fmt.Println("\n=== Ceph Configuration ===") + if c.Opts.CephSubnet == "" { + c.Opts.CephSubnet = c.promptString("Ceph nodes subnet (CIDR)", "10.53.101.0/24") + } + + if len(c.Opts.CephHosts) == 0 { + numHosts := c.promptInt("Number of Ceph hosts", 3) + c.Opts.CephHosts = make([]CephHostConfig, numHosts) + for i := 0; i < numHosts; i++ { + fmt.Printf("\nCeph Host %d:\n", i+1) + c.Opts.CephHosts[i].Hostname = c.promptString(" Hostname (as shown by 'hostname' command)", fmt.Sprintf("ceph-node-%d", i)) + c.Opts.CephHosts[i].IPAddress = c.promptString(" IP address", fmt.Sprintf("10.53.101.%d", i+2)) + c.Opts.CephHosts[i].IsMaster = (i == 0) + } + } + c.Opts.GenerateKeys = true + + fmt.Println("\n=== Kubernetes Configuration ===") + if !c.Opts.NonInteractive { + c.Opts.K8sManaged = c.promptBool("Use Codesphere-managed Kubernetes (k0s)", true) + } + + if c.Opts.K8sManaged { + if c.Opts.K8sAPIServer == "" { + c.Opts.K8sAPIServer = c.promptString("Kubernetes API server host (LB/DNS/IP)", "10.50.0.2") + } + if len(c.Opts.K8sControlPlane) == 0 { + c.Opts.K8sControlPlane = c.promptStringSlice("Control plane IP addresses (comma-separated)", []string{"10.50.0.2"}) + } + if len(c.Opts.K8sWorkers) == 0 { + c.Opts.K8sWorkers = c.promptStringSlice("Worker node IP addresses (comma-separated)", []string{"10.50.0.2", "10.50.0.3", "10.50.0.4"}) + } + } else { + if c.Opts.K8sPodCIDR == "" { + c.Opts.K8sPodCIDR = c.promptString("Pod CIDR of external cluster", "100.96.0.0/11") + } + if c.Opts.K8sServiceCIDR == "" { + c.Opts.K8sServiceCIDR = c.promptString("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") + } + + fmt.Println("\n=== Cluster Gateway Configuration ===") + if c.Opts.ClusterGatewayType == "" { + c.Opts.ClusterGatewayType = c.promptChoice("Gateway service type", []string{"LoadBalancer", "ExternalIP"}, "LoadBalancer") + } + if c.Opts.ClusterGatewayType == "ExternalIP" && len(c.Opts.ClusterGatewayIPs) == 0 { + c.Opts.ClusterGatewayIPs = c.promptStringSlice("Gateway IP addresses (comma-separated)", []string{"10.51.0.2", "10.51.0.3"}) + } + + if c.Opts.ClusterPublicGatewayType == "" { + c.Opts.ClusterPublicGatewayType = c.promptChoice("Public gateway service type", []string{"LoadBalancer", "ExternalIP"}, "LoadBalancer") + } + if c.Opts.ClusterPublicGatewayType == "ExternalIP" && len(c.Opts.ClusterPublicGatewayIPs) == 0 { + c.Opts.ClusterPublicGatewayIPs = c.promptStringSlice("Public gateway IP addresses (comma-separated)", []string{"10.52.0.2", "10.52.0.3"}) + } + + fmt.Println("\n=== MetalLB Configuration (Optional) ===") + if !c.Opts.NonInteractive { + c.Opts.MetalLBEnabled = c.promptBool("Enable MetalLB", false) + if c.Opts.MetalLBEnabled { + numPools := c.promptInt("Number of MetalLB IP pools", 1) + c.Opts.MetalLBPools = make([]MetalLBPool, numPools) + for i := 0; i < numPools; i++ { + fmt.Printf("\nMetalLB Pool %d:\n", i+1) + c.Opts.MetalLBPools[i].Name = c.promptString(" Pool name", fmt.Sprintf("pool-%d", i+1)) + c.Opts.MetalLBPools[i].IPAddresses = c.promptStringSlice(" IP addresses/ranges (comma-separated)", []string{"10.10.10.100-10.10.10.200"}) + } + } + } + + fmt.Println("\n=== Codesphere Application Configuration ===") + if c.Opts.CodesphereDomain == "" { + c.Opts.CodesphereDomain = c.promptString("Main Codesphere domain", "codesphere.yourcompany.com") + } + if c.Opts.CodesphereWorkspaceBaseDomain == "" { + c.Opts.CodesphereWorkspaceBaseDomain = c.promptString("Workspace base domain (*.domain should point to public gateway)", "ws.yourcompany.com") + } + if c.Opts.CodespherePublicIP == "" { + c.Opts.CodespherePublicIP = c.promptString("Primary public IP for workspaces", "") + } + if c.Opts.CodesphereCustomDomainBaseDomain == "" { + c.Opts.CodesphereCustomDomainBaseDomain = c.promptString("Custom domain CNAME base", "custom.yourcompany.com") + } + if len(c.Opts.CodesphereDNSServers) == 0 { + c.Opts.CodesphereDNSServers = c.promptStringSlice("DNS servers (comma-separated)", []string{"1.1.1.1", "8.8.8.8"}) + } + + fmt.Println("\n=== Workspace Plans Configuration ===") + if c.Opts.CodesphereWorkspaceImageBomRef == "" { + c.Opts.CodesphereWorkspaceImageBomRef = c.promptString("Workspace agent image BOM reference", "workspace-agent-24.04") + } + if c.Opts.CodesphereHostingPlanCPU == 0 { + c.Opts.CodesphereHostingPlanCPU = c.promptInt("Hosting plan CPU (tenths, e.g., 10 = 1 core)", 10) + } + if c.Opts.CodesphereHostingPlanMemory == 0 { + c.Opts.CodesphereHostingPlanMemory = c.promptInt("Hosting plan memory (MB)", 2048) + } + if c.Opts.CodesphereHostingPlanStorage == 0 { + c.Opts.CodesphereHostingPlanStorage = c.promptInt("Hosting plan storage (MB)", 20480) + } + if c.Opts.CodesphereHostingPlanTempStorage == 0 { + c.Opts.CodesphereHostingPlanTempStorage = c.promptInt("Hosting plan temp storage (MB)", 1024) + } + if c.Opts.CodesphereWorkspacePlanName == "" { + c.Opts.CodesphereWorkspacePlanName = c.promptString("Workspace plan name", "Standard Developer") + } + if c.Opts.CodesphereWorkspacePlanMaxReplica == 0 { + c.Opts.CodesphereWorkspacePlanMaxReplica = c.promptInt("Max replicas per workspace", 3) + } + + return nil +} + +func (c *InitInstallConfigCmd) buildGen0Config(secrets *GeneratedSecrets) *Gen0Config { + config := &Gen0Config{ + DataCenter: DataCenterConfig{ + ID: c.Opts.DatacenterID, + Name: c.Opts.DatacenterName, + City: c.Opts.DatacenterCity, + CountryCode: c.Opts.DatacenterCountryCode, + }, + Secrets: SecretsConfig{ + BaseDir: c.Opts.SecretsBaseDir, + }, + Ceph: CephConfig{ + CephAdmSSHKey: CephSSHKey{ + PublicKey: secrets.CephSSHPublicKey, + }, + NodesSubnet: c.Opts.CephSubnet, + Hosts: make([]CephHost, len(c.Opts.CephHosts)), + OSDs: []CephOSD{ + { + SpecID: "default", + Placement: CephPlacement{ + HostPattern: "*", + }, + DataDevices: CephDataDevices{ + Size: "240G:300G", + Limit: 1, + }, + DBDevices: CephDBDevices{ + Size: "120G:150G", + Limit: 1, + }, + }, + }, + }, + Cluster: ClusterConfig{ + Certificates: ClusterCertificates{ + CA: CAConfig{ + Algorithm: "RSA", + KeySizeBits: 2048, + CertPem: secrets.IngressCACert, + }, + }, + Gateway: GatewayConfig{ + ServiceType: c.Opts.ClusterGatewayType, + IPAddresses: c.Opts.ClusterGatewayIPs, + }, + PublicGateway: GatewayConfig{ + ServiceType: c.Opts.ClusterPublicGatewayType, + IPAddresses: c.Opts.ClusterPublicGatewayIPs, + }, + }, + Codesphere: CodesphereConfig{ + Domain: c.Opts.CodesphereDomain, + WorkspaceHostingBaseDomain: c.Opts.CodesphereWorkspaceBaseDomain, + PublicIP: c.Opts.CodespherePublicIP, + CustomDomains: CustomDomainsConfig{ + CNameBaseDomain: c.Opts.CodesphereCustomDomainBaseDomain, + }, + DNSServers: c.Opts.CodesphereDNSServers, + Experiments: []string{}, + DeployConfig: DeployConfig{ + Images: map[string]DeployImage{ + "ubuntu-24.04": { + Name: "Ubuntu 24.04", + SupportedUntil: "2028-05-31", + Flavors: map[string]DeployFlavor{ + "default": { + Image: ImageRef{ + BomRef: c.Opts.CodesphereWorkspaceImageBomRef, + }, + Pool: map[int]int{1: 1}, + }, + }, + }, + }, + }, + Plans: PlansConfig{ + HostingPlans: map[int]HostingPlan{ + 1: { + CPUTenth: c.Opts.CodesphereHostingPlanCPU, + GPUParts: 0, + MemoryMb: c.Opts.CodesphereHostingPlanMemory, + StorageMb: c.Opts.CodesphereHostingPlanStorage, + TempStorageMb: c.Opts.CodesphereHostingPlanTempStorage, + }, + }, + WorkspacePlans: map[int]WorkspacePlan{ + 1: { + Name: c.Opts.CodesphereWorkspacePlanName, + HostingPlanID: 1, + MaxReplicas: c.Opts.CodesphereWorkspacePlanMaxReplica, + OnDemand: true, + }, + }, + }, + }, + } + + for i, host := range c.Opts.CephHosts { + config.Ceph.Hosts[i] = CephHost(host) + } + + if c.Opts.RegistryServer != "" { + config.Registry = &RegistryConfig{ + Server: c.Opts.RegistryServer, + ReplaceImagesInBom: c.Opts.RegistryReplaceImages, + LoadContainerImages: c.Opts.RegistryLoadContainerImgs, + } + } + + if c.Opts.PostgresMode == "install" { + config.Postgres = PostgresConfig{ + CACertPem: secrets.PostgresCACert, + Primary: &PostgresPrimaryConfig{ + SSLConfig: SSLConfig{ + ServerCertPem: secrets.PostgresPrimaryCert, + }, + IP: c.Opts.PostgresPrimaryIP, + Hostname: c.Opts.PostgresPrimaryHost, + }, + } + if c.Opts.PostgresReplicaIP != "" { + config.Postgres.Replica = &PostgresReplicaConfig{ + IP: c.Opts.PostgresReplicaIP, + Name: c.Opts.PostgresReplicaName, + SSLConfig: SSLConfig{ + ServerCertPem: secrets.PostgresReplicaCert, + }, + } + } + } else { + config.Postgres = PostgresConfig{ + ServerAddress: c.Opts.PostgresExternal, + } + } + + config.Kubernetes = KubernetesConfig{ + ManagedByCodesphere: c.Opts.K8sManaged, + } + if c.Opts.K8sManaged { + config.Kubernetes.APIServerHost = c.Opts.K8sAPIServer + config.Kubernetes.ControlPlanes = make([]K8sNode, len(c.Opts.K8sControlPlane)) + for i, ip := range c.Opts.K8sControlPlane { + config.Kubernetes.ControlPlanes[i] = K8sNode{IPAddress: ip} + } + config.Kubernetes.Workers = make([]K8sNode, len(c.Opts.K8sWorkers)) + for i, ip := range c.Opts.K8sWorkers { + config.Kubernetes.Workers[i] = K8sNode{IPAddress: ip} + } + } else { + config.Kubernetes.PodCIDR = c.Opts.K8sPodCIDR + config.Kubernetes.ServiceCIDR = c.Opts.K8sServiceCIDR + } + + if c.Opts.MetalLBEnabled { + config.MetalLB = &MetalLBConfig{ + Enabled: true, + Pools: make([]MetalLBPoolDef, len(c.Opts.MetalLBPools)), + } + for i, pool := range c.Opts.MetalLBPools { + config.MetalLB.Pools[i] = MetalLBPoolDef(pool) + } + } + + config.ManagedServiceBackends = &ManagedServiceBackendsConfig{ + Postgres: make(map[string]interface{}), + } + + return config +} + +func (c *InitInstallConfigCmd) buildGen0Vault(secrets *GeneratedSecrets) *Gen0Vault { + vault := &Gen0Vault{ + Secrets: []SecretEntry{ + { + Name: "cephSshPrivateKey", + File: &SecretFile{ + Name: "id_rsa", + Content: secrets.CephSSHPrivateKey, + }, + }, + { + Name: "selfSignedCaKeyPem", + File: &SecretFile{ + Name: "key.pem", + Content: secrets.IngressCAKey, + }, + }, + { + Name: "domainAuthPrivateKey", + File: &SecretFile{ + Name: "key.pem", + Content: secrets.DomainAuthPrivateKey, + }, + }, + { + Name: "domainAuthPublicKey", + File: &SecretFile{ + Name: "key.pem", + Content: secrets.DomainAuthPublicKey, + }, + }, + }, + } + + if c.Opts.PostgresMode == "install" { + vault.Secrets = append(vault.Secrets, + SecretEntry{ + Name: "postgresPassword", + Fields: &SecretFields{ + Password: secrets.PostgresAdminPassword, + }, + }, + SecretEntry{ + Name: "postgresReplicaPassword", + Fields: &SecretFields{ + Password: secrets.PostgresReplicaPassword, + }, + }, + SecretEntry{ + Name: "postgresPrimaryServerKeyPem", + File: &SecretFile{ + Name: "primary.key", + Content: secrets.PostgresPrimaryKey, + }, + }, + ) + if c.Opts.PostgresReplicaIP != "" { + vault.Secrets = append(vault.Secrets, SecretEntry{ + Name: "postgresReplicaServerKeyPem", + File: &SecretFile{ + Name: "replica.key", + Content: secrets.PostgresReplicaKey, + }, + }) + } + } + + 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", + }, + }) + vault.Secrets = append(vault.Secrets, SecretEntry{ + Name: fmt.Sprintf("postgresPassword%s", capitalize(service)), + Fields: &SecretFields{ + Password: secrets.PostgresUserPasswords[service], + }, + }) + } + + vault.Secrets = append(vault.Secrets, SecretEntry{ + Name: "managedServiceSecrets", + Fields: &SecretFields{ + Password: "[]", + }, + }) + + if c.Opts.RegistryServer != "" { + vault.Secrets = append(vault.Secrets, + SecretEntry{ + Name: "registryUsername", + Fields: &SecretFields{ + Password: "YOUR_REGISTRY_USERNAME", + }, + }, + SecretEntry{ + Name: "registryPassword", + Fields: &SecretFields{ + Password: "YOUR_REGISTRY_PASSWORD", + }, + }, + ) + } + + if !c.Opts.K8sManaged { + 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", + }, + }) + } + + return vault +} + +func (c *InitInstallConfigCmd) promptString(prompt, defaultValue string) string { + if c.Opts.NonInteractive { + return defaultValue + } + + reader := bufio.NewReader(os.Stdin) + if defaultValue != "" { + fmt.Printf("%s (default: %s): ", prompt, defaultValue) + } else { + fmt.Printf("%s: ", prompt) + } + + input, _ := reader.ReadString('\n') + input = strings.TrimSpace(input) + + if input == "" { + return defaultValue + } + return input +} + +func (c *InitInstallConfigCmd) promptInt(prompt string, defaultValue int) int { + if c.Opts.NonInteractive { + return defaultValue + } + + reader := bufio.NewReader(os.Stdin) + fmt.Printf("%s (default: %d): ", prompt, defaultValue) + + input, _ := 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 (c *InitInstallConfigCmd) promptStringSlice(prompt string, defaultValue []string) []string { + if c.Opts.NonInteractive { + return defaultValue + } + + reader := bufio.NewReader(os.Stdin) + defaultStr := strings.Join(defaultValue, ", ") + if defaultStr != "" { + fmt.Printf("%s (default: %s): ", prompt, defaultStr) + } else { + fmt.Printf("%s: ", prompt) + } + + input, _ := 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 (c *InitInstallConfigCmd) promptBool(prompt string, defaultValue bool) bool { + if c.Opts.NonInteractive { + return defaultValue + } + + reader := bufio.NewReader(os.Stdin) + defaultStr := "n" + if defaultValue { + defaultStr = "y" + } + fmt.Printf("%s (y/n, default: %s): ", prompt, defaultStr) + + input, _ := reader.ReadString('\n') + input = strings.TrimSpace(strings.ToLower(input)) + + if input == "" { + return defaultValue + } + + return input == "y" || input == "yes" +} + +func (c *InitInstallConfigCmd) promptChoice(prompt string, choices []string, defaultValue string) string { + if c.Opts.NonInteractive { + return defaultValue + } + + reader := bufio.NewReader(os.Stdin) + fmt.Printf("%s [%s] (default: %s): ", prompt, strings.Join(choices, "/"), defaultValue) + + input, _ := 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 +} + +func capitalize(s string) string { + if s == "" { + return "" + } + s = strings.ReplaceAll(s, "_", "") + return strings.ToUpper(s[:1]) + s[1:] +} + +func (c *InitInstallConfigCmd) addConfigComments(yamlData []byte) []byte { + header := `# Codesphere Gen0 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 (c *InitInstallConfigCmd) addVaultComments(yamlData []byte) []byte { + header := `# Codesphere Gen0 Installer Secrets +# Generated by OMS CLI +# +# IMPORTANT: This file contains sensitive information! +# +# Before storing or transmitting this file: +# 1. Install SOPS and Age: brew install sops age +# 2. Generate an Age keypair: age-keygen -o age_key.txt +# 3. Encrypt this file: +# age-keygen -y age_key.txt # Get public key +# sops --encrypt --age --in-place prod.vault.yaml +# +# Keep the Age private key (age_key.txt) extremely secure! +# +# To edit the encrypted file later: +# export SOPS_AGE_KEY_FILE=/path/to/age_key.txt +# sops prod.vault.yaml + +` + return append([]byte(header), yamlData...) +} diff --git a/cli/cmd/init_install_config_gen0.go b/cli/cmd/init_install_config_gen0.go new file mode 100644 index 00000000..41953417 --- /dev/null +++ b/cli/cmd/init_install_config_gen0.go @@ -0,0 +1,319 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +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" +) + +type GeneratedSecrets struct { + CephSSHPrivateKey string + CephSSHPublicKey string + + IngressCAKey string + IngressCACert string + + DomainAuthPrivateKey string + DomainAuthPublicKey string + + PostgresCAKey string + PostgresCACert string + PostgresPrimaryKey string + PostgresPrimaryCert string + PostgresReplicaKey string + PostgresReplicaCert string + + PostgresAdminPassword string + PostgresReplicaPassword string + PostgresUserPasswords map[string]string + RegistryUsername string + RegistryPassword string +} + +func (c *InitInstallConfigCmd) generateSecrets() (*GeneratedSecrets, error) { + secrets := &GeneratedSecrets{ + PostgresUserPasswords: make(map[string]string), + } + + cephPrivKey, cephPubKey, err := generateSSHKeyPair() + if err != nil { + return nil, fmt.Errorf("failed to generate Ceph SSH key: %w", err) + } + secrets.CephSSHPrivateKey = cephPrivKey + secrets.CephSSHPublicKey = cephPubKey + + ingressCAKey, ingressCACert, err := generateCA("Codesphere Root CA", "DE", "Karlsruhe", "Codesphere") + if err != nil { + return nil, fmt.Errorf("failed to generate ingress CA: %w", err) + } + secrets.IngressCAKey = ingressCAKey + secrets.IngressCACert = ingressCACert + + domainPrivKey, domainPubKey, err := generateECDSAKeyPair() + if err != nil { + return nil, fmt.Errorf("failed to generate domain auth keys: %w", err) + } + secrets.DomainAuthPrivateKey = domainPrivKey + secrets.DomainAuthPublicKey = domainPubKey + + if c.Opts.PostgresMode == "install" { + pgCAKey, pgCACert, err := generateCA("PostgreSQL CA", "DE", "Karlsruhe", "Codesphere") + if err != nil { + return nil, fmt.Errorf("failed to generate PostgreSQL CA: %w", err) + } + secrets.PostgresCAKey = pgCAKey + secrets.PostgresCACert = pgCACert + + pgPrimaryKey, pgPrimaryCert, err := generateServerCertificate( + pgCAKey, + pgCACert, + c.Opts.PostgresPrimaryHost, + []string{c.Opts.PostgresPrimaryIP}, + ) + if err != nil { + return nil, fmt.Errorf("failed to generate PostgreSQL primary certificate: %w", err) + } + secrets.PostgresPrimaryKey = pgPrimaryKey + secrets.PostgresPrimaryCert = pgPrimaryCert + + if c.Opts.PostgresReplicaIP != "" { + pgReplicaKey, pgReplicaCert, err := generateServerCertificate( + pgCAKey, + pgCACert, + c.Opts.PostgresReplicaName, + []string{c.Opts.PostgresReplicaIP}, + ) + if err != nil { + return nil, fmt.Errorf("failed to generate PostgreSQL replica certificate: %w", err) + } + secrets.PostgresReplicaKey = pgReplicaKey + secrets.PostgresReplicaCert = pgReplicaCert + } + + secrets.PostgresAdminPassword = generatePassword(25) + secrets.PostgresReplicaPassword = generatePassword(25) + } + + services := []string{"auth", "deployment", "ide", "marketplace", "payment", "public_api", "team", "workspace"} + for _, service := range services { + secrets.PostgresUserPasswords[service] = generatePassword(20) + } + + return secrets, nil +} + +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 { + template.IPAddresses = append(template.IPAddresses, parseIP(ip)) + } + + 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 parseIP(ip string) net.IP { + return net.ParseIP(ip) +} + +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/cli/cmd/root.go b/cli/cmd/root.go index 2264f7d6..85617b75 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) AddLicensesCmd(rootCmd) // OMS API key management commands From ff27bdb480c20f0c5d072fae85cd2e8fc13c09b3 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Thu, 30 Oct 2025 14:33:50 +0100 Subject: [PATCH 02/24] test: Add tests for initialization and configuration builders --- cli/cmd/init_install_config_builders_test.go | 306 ++++++++++++++++ cli/cmd/init_install_config_gen0_test.go | 355 ++++++++++++++++++ cli/cmd/init_install_config_test.go | 356 +++++++++++++++++++ 3 files changed, 1017 insertions(+) create mode 100644 cli/cmd/init_install_config_builders_test.go create mode 100644 cli/cmd/init_install_config_gen0_test.go create mode 100644 cli/cmd/init_install_config_test.go diff --git a/cli/cmd/init_install_config_builders_test.go b/cli/cmd/init_install_config_builders_test.go new file mode 100644 index 00000000..9a6c4d4d --- /dev/null +++ b/cli/cmd/init_install_config_builders_test.go @@ -0,0 +1,306 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "strings" + "testing" +) + +func TestBuildGen0Config(t *testing.T) { + cmd := &InitInstallConfigCmd{ + Opts: &InitInstallConfigOpts{ + DatacenterID: 1, + DatacenterName: "test-dc", + DatacenterCity: "Berlin", + DatacenterCountryCode: "DE", + SecretsBaseDir: "/root/secrets", + CephSubnet: "10.53.101.0/24", + CephHosts: []CephHostConfig{{Hostname: "ceph-1", IPAddress: "10.53.101.2", IsMaster: true}}, + PostgresMode: "install", + PostgresPrimaryIP: "10.50.0.2", + PostgresPrimaryHost: "pg-primary", + PostgresReplicaIP: "10.50.0.3", + PostgresReplicaName: "replica1", + K8sManaged: true, + K8sAPIServer: "10.50.0.2", + K8sControlPlane: []string{"10.50.0.2"}, + K8sWorkers: []string{"10.50.0.3"}, + ClusterGatewayType: "LoadBalancer", + ClusterPublicGatewayType: "LoadBalancer", + CodesphereDomain: "codesphere.example.com", + CodesphereWorkspaceBaseDomain: "ws.example.com", + CodespherePublicIP: "1.2.3.4", + CodesphereCustomDomainBaseDomain: "custom.example.com", + CodesphereDNSServers: []string{"8.8.8.8"}, + CodesphereWorkspaceImageBomRef: "workspace-agent-24.04", + CodesphereHostingPlanCPU: 10, + CodesphereHostingPlanMemory: 2048, + CodesphereHostingPlanStorage: 20480, + CodesphereHostingPlanTempStorage: 1024, + CodesphereWorkspacePlanName: "Standard", + CodesphereWorkspacePlanMaxReplica: 3, + }, + } + + secrets := &GeneratedSecrets{ + CephSSHPublicKey: "ssh-rsa TEST", + IngressCACert: "-----BEGIN CERTIFICATE-----\nTEST\n-----END CERTIFICATE-----", + PostgresCACert: "-----BEGIN CERTIFICATE-----\nPG-CA\n-----END CERTIFICATE-----", + PostgresPrimaryCert: "-----BEGIN CERTIFICATE-----\nPG-PRIMARY\n-----END CERTIFICATE-----", + PostgresReplicaCert: "-----BEGIN CERTIFICATE-----\nPG-REPLICA\n-----END CERTIFICATE-----", + PostgresUserPasswords: map[string]string{"auth": "password123"}, + } + + config := cmd.buildGen0Config(secrets) + + if config.DataCenter.ID != 1 { + t.Errorf("DataCenter.ID = %d, want 1", config.DataCenter.ID) + } + if config.DataCenter.Name != "test-dc" { + t.Errorf("DataCenter.Name = %s, want test-dc", config.DataCenter.Name) + } + + if len(config.Ceph.Hosts) != 1 { + t.Errorf("len(Ceph.Hosts) = %d, want 1", len(config.Ceph.Hosts)) + } + if config.Ceph.Hosts[0].Hostname != "ceph-1" { + t.Errorf("Ceph.Hosts[0].Hostname = %s, want ceph-1", config.Ceph.Hosts[0].Hostname) + } + if config.Ceph.CephAdmSSHKey.PublicKey != "ssh-rsa TEST" { + t.Error("Ceph SSH public key not set correctly") + } + + if config.Postgres.CACertPem == "" { + t.Error("Postgres.CACertPem should not be empty") + } + if config.Postgres.Primary == nil { + t.Fatal("Postgres.Primary should not be nil") + } + if config.Postgres.Primary.IP != "10.50.0.2" { + t.Errorf("Postgres.Primary.IP = %s, want 10.50.0.2", config.Postgres.Primary.IP) + } + if config.Postgres.Replica == nil { + t.Fatal("Postgres.Replica should not be nil") + } + + if !config.Kubernetes.ManagedByCodesphere { + t.Error("Kubernetes.ManagedByCodesphere should be true") + } + if len(config.Kubernetes.ControlPlanes) != 1 { + t.Errorf("len(Kubernetes.ControlPlanes) = %d, want 1", len(config.Kubernetes.ControlPlanes)) + } + + if config.Codesphere.Domain != "codesphere.example.com" { + t.Errorf("Codesphere.Domain = %s, want codesphere.example.com", config.Codesphere.Domain) + } + if len(config.Codesphere.Plans.HostingPlans) != 1 { + t.Error("Should have one hosting plan") + } + if len(config.Codesphere.Plans.WorkspacePlans) != 1 { + t.Error("Should have one workspace plan") + } +} + +func TestBuildGen0ConfigExternalPostgres(t *testing.T) { + cmd := &InitInstallConfigCmd{ + Opts: &InitInstallConfigOpts{ + DatacenterID: 1, + DatacenterName: "test-dc", + DatacenterCity: "Berlin", + DatacenterCountryCode: "DE", + SecretsBaseDir: "/root/secrets", + CephSubnet: "10.53.101.0/24", + CephHosts: []CephHostConfig{{Hostname: "ceph-1", IPAddress: "10.53.101.2", IsMaster: true}}, + PostgresMode: "external", + PostgresExternal: "postgres.example.com:5432", + K8sManaged: false, + K8sPodCIDR: "100.96.0.0/11", + K8sServiceCIDR: "100.64.0.0/13", + ClusterGatewayType: "LoadBalancer", + ClusterPublicGatewayType: "LoadBalancer", + CodesphereDomain: "codesphere.example.com", + CodesphereWorkspaceBaseDomain: "ws.example.com", + CodesphereCustomDomainBaseDomain: "custom.example.com", + CodesphereDNSServers: []string{"8.8.8.8"}, + CodesphereWorkspaceImageBomRef: "workspace-agent-24.04", + CodesphereHostingPlanCPU: 10, + CodesphereHostingPlanMemory: 2048, + CodesphereHostingPlanStorage: 20480, + CodesphereHostingPlanTempStorage: 1024, + CodesphereWorkspacePlanName: "Standard", + CodesphereWorkspacePlanMaxReplica: 3, + }, + } + + secrets := &GeneratedSecrets{ + CephSSHPublicKey: "ssh-rsa TEST", + IngressCACert: "-----BEGIN CERTIFICATE-----\nTEST\n-----END CERTIFICATE-----", + } + + config := cmd.buildGen0Config(secrets) + + if config.Postgres.ServerAddress != "postgres.example.com:5432" { + t.Errorf("Postgres.ServerAddress = %s, want postgres.example.com:5432", config.Postgres.ServerAddress) + } + if config.Postgres.Primary != nil { + t.Error("Postgres.Primary should be nil for external mode") + } + + if config.Kubernetes.ManagedByCodesphere { + t.Error("Kubernetes.ManagedByCodesphere should be false") + } + if config.Kubernetes.PodCIDR != "100.96.0.0/11" { + t.Errorf("Kubernetes.PodCIDR = %s, want 100.96.0.0/11", config.Kubernetes.PodCIDR) + } +} + +func TestBuildGen0Vault(t *testing.T) { + cmd := &InitInstallConfigCmd{ + Opts: &InitInstallConfigOpts{ + PostgresMode: "install", + PostgresReplicaIP: "10.50.0.3", + RegistryServer: "ghcr.io", + K8sManaged: true, + }, + } + + secrets := &GeneratedSecrets{ + CephSSHPrivateKey: "-----BEGIN RSA PRIVATE KEY-----\nCEPH\n-----END RSA PRIVATE KEY-----", + IngressCAKey: "-----BEGIN RSA PRIVATE KEY-----\nCA\n-----END RSA PRIVATE KEY-----", + DomainAuthPrivateKey: "-----BEGIN EC PRIVATE KEY-----\nDOMAIN\n-----END EC PRIVATE KEY-----", + DomainAuthPublicKey: "-----BEGIN PUBLIC KEY-----\nDOMAIN-PUB\n-----END PUBLIC KEY-----", + PostgresAdminPassword: "admin123", + PostgresReplicaPassword: "replica123", + PostgresPrimaryKey: "-----BEGIN RSA PRIVATE KEY-----\nPG-PRIMARY\n-----END RSA PRIVATE KEY-----", + PostgresReplicaKey: "-----BEGIN RSA PRIVATE KEY-----\nPG-REPLICA\n-----END RSA PRIVATE KEY-----", + PostgresUserPasswords: map[string]string{ + "auth": "auth-pass", + "deployment": "deployment-pass", + "ide": "ide-pass", + }, + } + + vault := cmd.buildGen0Vault(secrets) + + expectedSecrets := map[string]bool{ + "cephSshPrivateKey": false, + "selfSignedCaKeyPem": false, + "domainAuthPrivateKey": false, + "domainAuthPublicKey": false, + "postgresPassword": false, + "postgresReplicaPassword": false, + "postgresPrimaryServerKeyPem": false, + "postgresReplicaServerKeyPem": false, + "registryUsername": false, + "registryPassword": false, + "managedServiceSecrets": false, + } + + for _, secret := range vault.Secrets { + if _, exists := expectedSecrets[secret.Name]; exists { + expectedSecrets[secret.Name] = true + } + } + + for name, found := range expectedSecrets { + if !found && strings.HasPrefix(name, "postgres") { + t.Errorf("Expected postgres secret %s not found in vault", name) + } + } + + foundCephSSH := false + foundIngressCA := false + for _, secret := range vault.Secrets { + if secret.Name == "cephSshPrivateKey" { + foundCephSSH = true + if secret.File == nil { + t.Error("cephSshPrivateKey should have a file") + } else if secret.File.Name != "id_rsa" { + t.Errorf("cephSshPrivateKey file name = %s, want id_rsa", secret.File.Name) + } + } + if secret.Name == "selfSignedCaKeyPem" { + foundIngressCA = true + if secret.File == nil { + t.Error("selfSignedCaKeyPem should have a file") + } + } + } + + if !foundCephSSH { + t.Error("cephSshPrivateKey not found in vault") + } + if !foundIngressCA { + t.Error("selfSignedCaKeyPem not found in vault") + } +} + +func TestBuildGen0VaultExternalK8s(t *testing.T) { + cmd := &InitInstallConfigCmd{ + Opts: &InitInstallConfigOpts{ + PostgresMode: "external", + K8sManaged: false, + }, + } + + secrets := &GeneratedSecrets{ + CephSSHPrivateKey: "-----BEGIN RSA PRIVATE KEY-----\nCEPH\n-----END RSA PRIVATE KEY-----", + IngressCAKey: "-----BEGIN RSA PRIVATE KEY-----\nCA\n-----END RSA PRIVATE KEY-----", + DomainAuthPrivateKey: "-----BEGIN EC PRIVATE KEY-----\nDOMAIN\n-----END EC PRIVATE KEY-----", + DomainAuthPublicKey: "-----BEGIN PUBLIC KEY-----\nDOMAIN-PUB\n-----END PUBLIC KEY-----", + } + + vault := cmd.buildGen0Vault(secrets) + + foundKubeConfig := false + for _, secret := range vault.Secrets { + if secret.Name == "kubeConfig" { + foundKubeConfig = true + if secret.File == nil { + t.Error("kubeConfig should have a file") + } + } + } + + if !foundKubeConfig { + t.Error("kubeConfig not found in vault for external Kubernetes") + } +} + +func TestAddConfigComments(t *testing.T) { + cmd := &InitInstallConfigCmd{} + yamlData := []byte("test: value\n") + + result := cmd.addConfigComments(yamlData) + resultStr := string(result) + + if !strings.Contains(resultStr, "Codesphere Gen0 Installer Configuration") { + t.Error("Config comments should contain header text") + } + if !strings.Contains(resultStr, "test: value") { + t.Error("Config comments should preserve original YAML") + } +} + +func TestAddVaultComments(t *testing.T) { + cmd := &InitInstallConfigCmd{} + yamlData := []byte("secrets:\n - name: test\n") + + result := cmd.addVaultComments(yamlData) + resultStr := string(result) + + if !strings.Contains(resultStr, "Codesphere Gen0 Installer Secrets") { + t.Error("Vault comments should contain header text") + } + if !strings.Contains(resultStr, "IMPORTANT") { + t.Error("Vault comments should contain security warning") + } + if !strings.Contains(resultStr, "SOPS") { + t.Error("Vault comments should mention SOPS") + } + if !strings.Contains(resultStr, "secrets:") { + t.Error("Vault comments should preserve original YAML") + } +} diff --git a/cli/cmd/init_install_config_gen0_test.go b/cli/cmd/init_install_config_gen0_test.go new file mode 100644 index 00000000..65e582c6 --- /dev/null +++ b/cli/cmd/init_install_config_gen0_test.go @@ -0,0 +1,355 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "strings" + "testing" + + "golang.org/x/crypto/ssh" +) + +func TestGenerateSSHKeyPair(t *testing.T) { + privKey, pubKey, err := generateSSHKeyPair() + if err != nil { + t.Fatalf("generateSSHKeyPair failed: %v", err) + } + + if !strings.HasPrefix(privKey, "-----BEGIN RSA PRIVATE KEY-----") { + t.Error("Private key should be in PEM format") + } + + _, _, _, _, err = ssh.ParseAuthorizedKey([]byte(pubKey)) + if err != nil { + t.Errorf("Failed to parse SSH public key: %v", err) + } + + block, _ := pem.Decode([]byte(privKey)) + if block == nil { + t.Fatal("Failed to decode private key PEM") + } + if block.Type != "RSA PRIVATE KEY" { + t.Errorf("Expected RSA PRIVATE KEY, got %s", block.Type) + } +} + +func TestGenerateCA(t *testing.T) { + keyPEM, certPEM, err := generateCA("Test CA", "DE", "Berlin", "TestOrg") + if err != nil { + t.Fatalf("generateCA failed: %v", err) + } + + if !strings.HasPrefix(keyPEM, "-----BEGIN RSA PRIVATE KEY-----") { + t.Error("CA key should be in PEM format") + } + + if !strings.HasPrefix(certPEM, "-----BEGIN CERTIFICATE-----") { + t.Error("CA cert should be in PEM format") + } + + certBlock, _ := pem.Decode([]byte(certPEM)) + if certBlock == nil { + t.Fatal("Failed to decode certificate PEM") + } + + cert, err := x509.ParseCertificate(certBlock.Bytes) + if err != nil { + t.Fatalf("Failed to parse certificate: %v", err) + } + + if !cert.IsCA { + t.Error("Certificate should be a CA") + } + if cert.Subject.CommonName != "Test CA" { + t.Errorf("Expected CN 'Test CA', got '%s'", cert.Subject.CommonName) + } + if len(cert.Subject.Country) == 0 || cert.Subject.Country[0] != "DE" { + t.Error("Expected country DE") + } + if len(cert.Subject.Locality) == 0 || cert.Subject.Locality[0] != "Berlin" { + t.Error("Expected locality Berlin") + } + if len(cert.Subject.Organization) == 0 || cert.Subject.Organization[0] != "TestOrg" { + t.Error("Expected organization TestOrg") + } +} + +func TestGenerateServerCertificate(t *testing.T) { + caKeyPEM, caCertPEM, err := generateCA("Test CA", "DE", "Berlin", "TestOrg") + if err != nil { + t.Fatalf("Failed to generate CA: %v", err) + } + + serverKeyPEM, serverCertPEM, err := generateServerCertificate( + caKeyPEM, + caCertPEM, + "test-server", + []string{"192.168.1.1", "10.0.0.1"}, + ) + if err != nil { + t.Fatalf("generateServerCertificate failed: %v", err) + } + + if !strings.HasPrefix(serverKeyPEM, "-----BEGIN RSA PRIVATE KEY-----") { + t.Error("Server key should be in PEM format") + } + + if !strings.HasPrefix(serverCertPEM, "-----BEGIN CERTIFICATE-----") { + t.Error("Server cert should be in PEM format") + } + + certBlock, _ := pem.Decode([]byte(serverCertPEM)) + if certBlock == nil { + t.Fatal("Failed to decode server certificate PEM") + } + + cert, err := x509.ParseCertificate(certBlock.Bytes) + if err != nil { + t.Fatalf("Failed to parse server certificate: %v", err) + } + + if cert.Subject.CommonName != "test-server" { + t.Errorf("Expected CN 'test-server', got '%s'", cert.Subject.CommonName) + } + + if len(cert.IPAddresses) != 2 { + t.Errorf("Expected 2 IP addresses, got %d", len(cert.IPAddresses)) + } +} + +func TestGenerateECDSAKeyPair(t *testing.T) { + privKey, pubKey, err := generateECDSAKeyPair() + if err != nil { + t.Fatalf("generateECDSAKeyPair failed: %v", err) + } + + if !strings.HasPrefix(privKey, "-----BEGIN EC PRIVATE KEY-----") { + t.Error("Private key should be in EC PEM format") + } + + if !strings.HasPrefix(pubKey, "-----BEGIN PUBLIC KEY-----") { + t.Error("Public key should be in PEM format") + } + + privBlock, _ := pem.Decode([]byte(privKey)) + if privBlock == nil { + t.Fatal("Failed to decode private key PEM") + } + if privBlock.Type != "EC PRIVATE KEY" { + t.Errorf("Expected EC PRIVATE KEY, got %s", privBlock.Type) + } + + pubBlock, _ := pem.Decode([]byte(pubKey)) + if pubBlock == nil { + t.Fatal("Failed to decode public key PEM") + } + if pubBlock.Type != "PUBLIC KEY" { + t.Errorf("Expected PUBLIC KEY, got %s", pubBlock.Type) + } +} + +func TestGeneratePassword(t *testing.T) { + password := generatePassword(20) + + if len(password) != 20 { + t.Errorf("Expected password length 20, got %d", len(password)) + } + + password2 := generatePassword(20) + if password == password2 { + t.Error("Generated passwords should be different") + } +} + +func TestParseIP(t *testing.T) { + tests := []struct { + name string + ip string + wantNil bool + }{ + {"valid IPv4", "192.168.1.1", false}, + {"valid IPv6", "2001:db8::1", false}, + {"invalid IP", "not-an-ip", true}, + {"empty string", "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseIP(tt.ip) + if tt.wantNil && result != nil { + t.Errorf("parseIP(%s) should return nil, got %v", tt.ip, result) + } + if !tt.wantNil && result == nil { + t.Errorf("parseIP(%s) should not return nil", tt.ip) + } + }) + } +} + +func TestParseCAKeyAndCert(t *testing.T) { + caKeyPEM, caCertPEM, err := generateCA("Test CA", "DE", "Berlin", "TestOrg") + if err != nil { + t.Fatalf("Failed to generate CA: %v", err) + } + + caKey, caCert, err := parseCAKeyAndCert(caKeyPEM, caCertPEM) + if err != nil { + t.Fatalf("parseCAKeyAndCert failed: %v", err) + } + if caKey == nil { + t.Error("CA key should not be nil") + } + if caCert == nil { + t.Error("CA cert should not be nil") + } + + _, _, err = parseCAKeyAndCert("invalid-pem", caCertPEM) + if err == nil { + t.Error("Expected error for invalid key PEM") + } + + _, _, err = parseCAKeyAndCert(caKeyPEM, "invalid-pem") + if err == nil { + t.Error("Expected error for invalid cert PEM") + } +} + +func TestEncodePEMKey(t *testing.T) { + tests := []struct { + name string + keyType string + wantErr bool + }{ + {"RSA key", "RSA", false}, + {"EC key", "EC", false}, + {"invalid type", "INVALID", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var key interface{} + var err error + + switch tt.keyType { + case "RSA": + key, err = generateTestRSAKey() + case "EC": + key, err = generateTestECKey() + default: + key = "invalid-key" + } + + if err != nil && !tt.wantErr { + t.Fatalf("Failed to generate test key: %v", err) + } + + result, err := encodePEMKey(key, tt.keyType) + if (err != nil) != tt.wantErr { + t.Errorf("encodePEMKey() error = %v, wantErr %v", err, tt.wantErr) + } + + if !tt.wantErr && !strings.Contains(result, "-----BEGIN") { + t.Error("Result should be in PEM format") + } + }) + } +} + +func TestEncodePEMCert(t *testing.T) { + _, certPEM, err := generateCA("Test CA", "DE", "Berlin", "TestOrg") + if err != nil { + t.Fatalf("Failed to generate CA: %v", err) + } + + certBlock, _ := pem.Decode([]byte(certPEM)) + if certBlock == nil { + t.Fatal("Failed to decode certificate PEM") + } + + result := encodePEMCert(certBlock.Bytes) + + if !strings.HasPrefix(result, "-----BEGIN CERTIFICATE-----") { + t.Error("Result should be in PEM format") + } +} + +func TestGenerateSecrets(t *testing.T) { + cmd := &InitInstallConfigCmd{ + Opts: &InitInstallConfigOpts{ + PostgresMode: "install", + PostgresPrimaryHost: "pg-primary", + PostgresPrimaryIP: "10.0.0.1", + PostgresReplicaIP: "10.0.0.2", + PostgresReplicaName: "replica1", + }, + } + + secrets, err := cmd.generateSecrets() + if err != nil { + t.Fatalf("generateSecrets failed: %v", err) + } + + if secrets.CephSSHPrivateKey == "" { + t.Error("Ceph SSH private key should not be empty") + } + if secrets.CephSSHPublicKey == "" { + t.Error("Ceph SSH public key should not be empty") + } + + if secrets.IngressCAKey == "" { + t.Error("Ingress CA key should not be empty") + } + if secrets.IngressCACert == "" { + t.Error("Ingress CA cert should not be empty") + } + + if secrets.DomainAuthPrivateKey == "" { + t.Error("Domain auth private key should not be empty") + } + if secrets.DomainAuthPublicKey == "" { + t.Error("Domain auth public key should not be empty") + } + + if secrets.PostgresCACert == "" { + t.Error("PostgreSQL CA cert should not be empty") + } + if secrets.PostgresPrimaryCert == "" { + t.Error("PostgreSQL primary cert should not be empty") + } + if secrets.PostgresReplicaCert == "" { + t.Error("PostgreSQL replica cert should not be empty") + } + + if secrets.PostgresAdminPassword == "" { + t.Error("PostgreSQL admin password should not be empty") + } + if secrets.PostgresReplicaPassword == "" { + t.Error("PostgreSQL replica password should not be empty") + } + + expectedServices := []string{"auth", "deployment", "ide", "marketplace", "payment", "public_api", "team", "workspace"} + for _, service := range expectedServices { + if _, ok := secrets.PostgresUserPasswords[service]; !ok { + t.Errorf("Missing password for service: %s", service) + } + } +} + +func generateTestRSAKey() (interface{}, error) { + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, err + } + return rsaKey, nil +} + +func generateTestECKey() (interface{}, error) { + return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) +} diff --git a/cli/cmd/init_install_config_test.go b/cli/cmd/init_install_config_test.go new file mode 100644 index 00000000..4c9b3b62 --- /dev/null +++ b/cli/cmd/init_install_config_test.go @@ -0,0 +1,356 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "os" + "strings" + "testing" + + "github.com/codesphere-cloud/oms/internal/util" +) + +func TestIsValidIP(t *testing.T) { + tests := []struct { + name string + ip string + valid bool + }{ + {"valid IPv4", "192.168.1.1", true}, + {"valid IPv6", "2001:db8::1", true}, + {"invalid IP", "not-an-ip", false}, + {"empty string", "", false}, + {"partial IP", "192.168", false}, + {"localhost", "127.0.0.1", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isValidIP(tt.ip) + if result != tt.valid { + t.Errorf("isValidIP(%q) = %v, want %v", tt.ip, result, tt.valid) + } + }) + } +} + +func TestApplyProfile(t *testing.T) { + tests := []struct { + name string + profile string + wantErr bool + checkDatacenter string + }{ + {"dev profile", "dev", false, "dev"}, + {"development profile", "development", false, "dev"}, + {"prod profile", "prod", false, "production"}, + {"production profile", "production", false, "production"}, + {"minimal profile", "minimal", false, "minimal"}, + {"invalid profile", "invalid", true, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := &InitInstallConfigCmd{ + Opts: &InitInstallConfigOpts{ + Profile: tt.profile, + }, + } + + err := cmd.applyProfile() + if (err != nil) != tt.wantErr { + t.Errorf("applyProfile() error = %v, wantErr %v", err, tt.wantErr) + } + + if !tt.wantErr && cmd.Opts.DatacenterName != tt.checkDatacenter { + t.Errorf("DatacenterName = %s, want %s", cmd.Opts.DatacenterName, tt.checkDatacenter) + } + }) + } +} + +func TestApplyDevProfile(t *testing.T) { + cmd := &InitInstallConfigCmd{ + Opts: &InitInstallConfigOpts{ + Profile: "dev", + }, + } + + err := cmd.applyProfile() + if err != nil { + t.Fatalf("applyProfile failed: %v", err) + } + + if cmd.Opts.DatacenterID != 1 { + t.Errorf("DatacenterID = %d, want 1", cmd.Opts.DatacenterID) + } + if cmd.Opts.DatacenterName != "dev" { + t.Errorf("DatacenterName = %s, want dev", cmd.Opts.DatacenterName) + } + if cmd.Opts.PostgresMode != "install" { + t.Errorf("PostgresMode = %s, want install", cmd.Opts.PostgresMode) + } + if cmd.Opts.K8sManaged != true { + t.Error("K8sManaged should be true for dev profile") + } +} + +func TestValidateConfig(t *testing.T) { + configFile, err := os.CreateTemp("", "config-*.yaml") + if err != nil { + t.Fatalf("Failed to create temp config file: %v", err) + } + defer func() { _ = os.Remove(configFile.Name()) }() + + vaultFile, err := os.CreateTemp("", "vault-*.yaml") + if err != nil { + t.Fatalf("Failed to create temp vault file: %v", err) + } + defer func() { _ = os.Remove(vaultFile.Name()) }() + + 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-----" +` + + if _, err := configFile.WriteString(validConfig); err != nil { + t.Fatalf("Failed to write config: %v", err) + } + if err := configFile.Close(); err != nil { + t.Fatalf("Failed to close config file: %v", err) + } + + if _, err := vaultFile.WriteString(validVault); err != nil { + t.Fatalf("Failed to write vault: %v", err) + } + if err := vaultFile.Close(); err != nil { + t.Fatalf("Failed to close vault file: %v", err) + } + + cmd := &InitInstallConfigCmd{ + Opts: &InitInstallConfigOpts{ + ConfigFile: configFile.Name(), + VaultFile: vaultFile.Name(), + ValidateOnly: true, + }, + FileWriter: util.NewFilesystemWriter(), + } + + err = cmd.validateConfig() + if err != nil { + t.Errorf("validateConfig() failed for valid config: %v", err) + } +} + +func TestValidateConfigInvalidDatacenter(t *testing.T) { + configFile, err := os.CreateTemp("", "config-*.yaml") + if err != nil { + t.Fatalf("Failed to create temp config file: %v", err) + } + defer func() { _ = os.Remove(configFile.Name()) }() + + 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: {} +` + + if _, err := configFile.WriteString(invalidConfig); err != nil { + t.Fatalf("Failed to write config: %v", err) + } + if err := configFile.Close(); err != nil { + t.Fatalf("Failed to close config file: %v", err) + } + + cmd := &InitInstallConfigCmd{ + Opts: &InitInstallConfigOpts{ + ConfigFile: configFile.Name(), + ValidateOnly: true, + }, + FileWriter: util.NewFilesystemWriter(), + } + + err = cmd.validateConfig() + if err == nil { + t.Error("validateConfig() should fail for invalid config") + } +} + +func TestValidateConfigInvalidIP(t *testing.T) { + configFile, err := os.CreateTemp("", "config-*.yaml") + if err != nil { + t.Fatalf("Failed to create temp config file: %v", err) + } + defer func() { _ = os.Remove(configFile.Name()) }() + + 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: {} +` + + if _, err := configFile.WriteString(configWithInvalidIP); err != nil { + t.Fatalf("Failed to write config: %v", err) + } + if err := configFile.Close(); err != nil { + t.Fatalf("Failed to close config file: %v", err) + } + + cmd := &InitInstallConfigCmd{ + Opts: &InitInstallConfigOpts{ + ConfigFile: configFile.Name(), + ValidateOnly: true, + }, + FileWriter: util.NewFilesystemWriter(), + } + + err = cmd.validateConfig() + if err == nil { + t.Error("validateConfig() should fail for invalid IP address") + } + if err != nil && !strings.Contains(err.Error(), "invalid") { + t.Logf("Got error: %v", err) + } +} From 420e13e45aa33fe37fc94a16e63d407928ab1ffb Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Thu, 30 Oct 2025 14:00:31 +0000 Subject: [PATCH 03/24] chore(docs): Auto-update docs and licenses Signed-off-by: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> --- docs/README.md | 3 +- docs/oms-cli.md | 3 +- docs/oms-cli_beta.md | 2 +- docs/oms-cli_beta_extend.md | 2 +- docs/oms-cli_beta_extend_baseimage.md | 2 +- docs/oms-cli_download.md | 2 +- docs/oms-cli_download_package.md | 2 +- docs/oms-cli_init.md | 20 +++++++++ docs/oms-cli_init_install-config.md | 64 +++++++++++++++++++++++++++ docs/oms-cli_install.md | 2 +- docs/oms-cli_install_codesphere.md | 2 +- docs/oms-cli_licenses.md | 2 +- docs/oms-cli_list.md | 2 +- docs/oms-cli_list_api-keys.md | 2 +- docs/oms-cli_list_packages.md | 2 +- docs/oms-cli_register.md | 2 +- docs/oms-cli_revoke.md | 2 +- docs/oms-cli_revoke_api-key.md | 2 +- docs/oms-cli_update.md | 2 +- docs/oms-cli_update_api-key.md | 2 +- docs/oms-cli_update_oms.md | 2 +- docs/oms-cli_update_package.md | 2 +- docs/oms-cli_version.md | 2 +- 23 files changed, 107 insertions(+), 21 deletions(-) create mode 100644 docs/oms-cli_init.md create mode 100644 docs/oms-cli_init_install-config.md diff --git a/docs/README.md b/docs/README.md index 66c5ff68..accd8f1d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,6 +19,7 @@ like downloading new versions. * [oms-cli beta](oms-cli_beta.md) - Commands for early testing * [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 @@ -27,4 +28,4 @@ like downloading new versions. * [oms-cli update](oms-cli_update.md) - Update OMS related resources * [oms-cli version](oms-cli_version.md) - Print version -###### Auto generated by spf13/cobra on 23-Oct-2025 +###### Auto generated by spf13/cobra on 30-Oct-2025 diff --git a/docs/oms-cli.md b/docs/oms-cli.md index 66c5ff68..accd8f1d 100644 --- a/docs/oms-cli.md +++ b/docs/oms-cli.md @@ -19,6 +19,7 @@ like downloading new versions. * [oms-cli beta](oms-cli_beta.md) - Commands for early testing * [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 @@ -27,4 +28,4 @@ like downloading new versions. * [oms-cli update](oms-cli_update.md) - Update OMS related resources * [oms-cli version](oms-cli_version.md) - Print version -###### Auto generated by spf13/cobra on 23-Oct-2025 +###### Auto generated by spf13/cobra on 30-Oct-2025 diff --git a/docs/oms-cli_beta.md b/docs/oms-cli_beta.md index 7bb12f54..d158f643 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 23-Oct-2025 +###### Auto generated by spf13/cobra on 30-Oct-2025 diff --git a/docs/oms-cli_beta_extend.md b/docs/oms-cli_beta_extend.md index 386d605f..e92fb18b 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 23-Oct-2025 +###### Auto generated by spf13/cobra on 30-Oct-2025 diff --git a/docs/oms-cli_beta_extend_baseimage.md b/docs/oms-cli_beta_extend_baseimage.md index 20df5370..8ba53035 100644 --- a/docs/oms-cli_beta_extend_baseimage.md +++ b/docs/oms-cli_beta_extend_baseimage.md @@ -27,4 +27,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 23-Oct-2025 +###### Auto generated by spf13/cobra on 30-Oct-2025 diff --git a/docs/oms-cli_download.md b/docs/oms-cli_download.md index e8e52e89..e2655f03 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 23-Oct-2025 +###### Auto generated by spf13/cobra on 30-Oct-2025 diff --git a/docs/oms-cli_download_package.md b/docs/oms-cli_download_package.md index 1792f415..2c24b4fe 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 23-Oct-2025 +###### Auto generated by spf13/cobra on 30-Oct-2025 diff --git a/docs/oms-cli_init.md b/docs/oms-cli_init.md new file mode 100644 index 00000000..128a4a41 --- /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 Gen0 installer configuration files + +###### Auto generated by spf13/cobra on 30-Oct-2025 diff --git a/docs/oms-cli_init_install-config.md b/docs/oms-cli_init_install-config.md new file mode 100644 index 00000000..02ee50d4 --- /dev/null +++ b/docs/oms-cli_init_install-config.md @@ -0,0 +1,64 @@ +## oms-cli init install-config + +Initialize Codesphere Gen0 installer configuration files + +### Synopsis + +Initialize config.yaml and prod.vault.yaml for the Codesphere Gen0 installer. + +This command generates two files: +- config.yaml: Main configuration (infrastructure, networking, plans) +- prod.vault.yaml: Secrets file (keys, certificates, passwords) + +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 Gen0 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 + --k8s-control-plane strings K8s control plane IPs (comma-separated) + --k8s-managed Use Codesphere-managed Kubernetes (default true) + --non-interactive Use default values without prompting + --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 30-Oct-2025 diff --git a/docs/oms-cli_install.md b/docs/oms-cli_install.md index f40e5ad4..a8e5baea 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 23-Oct-2025 +###### Auto generated by spf13/cobra on 30-Oct-2025 diff --git a/docs/oms-cli_install_codesphere.md b/docs/oms-cli_install_codesphere.md index 030d3dc3..77840aad 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 23-Oct-2025 +###### Auto generated by spf13/cobra on 30-Oct-2025 diff --git a/docs/oms-cli_licenses.md b/docs/oms-cli_licenses.md index 4ee07539..b154ade1 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 23-Oct-2025 +###### Auto generated by spf13/cobra on 30-Oct-2025 diff --git a/docs/oms-cli_list.md b/docs/oms-cli_list.md index fdcda207..bb373a01 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 23-Oct-2025 +###### Auto generated by spf13/cobra on 30-Oct-2025 diff --git a/docs/oms-cli_list_api-keys.md b/docs/oms-cli_list_api-keys.md index df732f3c..39ec59cf 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 23-Oct-2025 +###### Auto generated by spf13/cobra on 30-Oct-2025 diff --git a/docs/oms-cli_list_packages.md b/docs/oms-cli_list_packages.md index 20845527..8aa6b561 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 23-Oct-2025 +###### Auto generated by spf13/cobra on 30-Oct-2025 diff --git a/docs/oms-cli_register.md b/docs/oms-cli_register.md index 787764e8..d9952934 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 23-Oct-2025 +###### Auto generated by spf13/cobra on 30-Oct-2025 diff --git a/docs/oms-cli_revoke.md b/docs/oms-cli_revoke.md index 25ef5d13..56ce2757 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 23-Oct-2025 +###### Auto generated by spf13/cobra on 30-Oct-2025 diff --git a/docs/oms-cli_revoke_api-key.md b/docs/oms-cli_revoke_api-key.md index 90a31831..49a2c28e 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 23-Oct-2025 +###### Auto generated by spf13/cobra on 30-Oct-2025 diff --git a/docs/oms-cli_update.md b/docs/oms-cli_update.md index 9fa9de78..661cc761 100644 --- a/docs/oms-cli_update.md +++ b/docs/oms-cli_update.md @@ -23,4 +23,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 23-Oct-2025 +###### Auto generated by spf13/cobra on 30-Oct-2025 diff --git a/docs/oms-cli_update_api-key.md b/docs/oms-cli_update_api-key.md index d7f35102..3ffecbd7 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 23-Oct-2025 +###### Auto generated by spf13/cobra on 30-Oct-2025 diff --git a/docs/oms-cli_update_oms.md b/docs/oms-cli_update_oms.md index 875562a0..75faa045 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 23-Oct-2025 +###### Auto generated by spf13/cobra on 30-Oct-2025 diff --git a/docs/oms-cli_update_package.md b/docs/oms-cli_update_package.md index d608c2dc..92a8a41c 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 23-Oct-2025 +###### Auto generated by spf13/cobra on 30-Oct-2025 diff --git a/docs/oms-cli_version.md b/docs/oms-cli_version.md index 96cd471f..24d6c59a 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 23-Oct-2025 +###### Auto generated by spf13/cobra on 30-Oct-2025 From 00b490bda0597d1dcda84b4cf0bb9e67c078d18c Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Thu, 6 Nov 2025 12:03:36 +0100 Subject: [PATCH 04/24] ref: simplify install config process --- cli/cmd/init_install_config.go | 538 ++------ cli/cmd/init_install_config_builders.go | 647 --------- cli/cmd/init_install_config_builders_test.go | 306 ----- cli/cmd/init_install_config_gen0_test.go | 355 ----- cli/cmd/init_install_config_test.go | 285 ++-- internal/installer/config_generator.go | 1165 +++++++++++++++++ internal/installer/config_generator_test.go | 216 +++ .../installer/crypto.go | 116 +- internal/installer/crypto_test.go | 115 ++ internal/installer/prompt.go | 145 ++ internal/installer/prompt_test.go | 305 +++++ 11 files changed, 2177 insertions(+), 2016 deletions(-) delete mode 100644 cli/cmd/init_install_config_builders.go delete mode 100644 cli/cmd/init_install_config_builders_test.go delete mode 100644 cli/cmd/init_install_config_gen0_test.go create mode 100644 internal/installer/config_generator.go create mode 100644 internal/installer/config_generator_test.go rename cli/cmd/init_install_config_gen0.go => internal/installer/crypto.go (60%) create mode 100644 internal/installer/crypto_test.go create mode 100644 internal/installer/prompt.go create mode 100644 internal/installer/prompt_test.go diff --git a/cli/cmd/init_install_config.go b/cli/cmd/init_install_config.go index 0e55f392..e8efdedc 100644 --- a/cli/cmd/init_install_config.go +++ b/cli/cmd/init_install_config.go @@ -5,19 +5,20 @@ package cmd import ( "fmt" - "net" + "io" "strings" - "github.com/codesphere-cloud/cs-go/pkg/io" + csio "github.com/codesphere-cloud/cs-go/pkg/io" + "github.com/codesphere-cloud/oms/internal/installer" "github.com/codesphere-cloud/oms/internal/util" "github.com/spf13/cobra" - "gopkg.in/yaml.v3" ) type InitInstallConfigCmd struct { cmd *cobra.Command Opts *InitInstallConfigOpts FileWriter util.FileIO + Generator *installer.ConfigGenerator } type InitInstallConfigOpts struct { @@ -29,7 +30,7 @@ type InitInstallConfigOpts struct { Profile string ValidateOnly bool WithComments bool - NonInteractive bool + Interactive bool GenerateKeys bool SecretsBaseDir string @@ -50,7 +51,7 @@ type InitInstallConfigOpts struct { PostgresExternal string CephSubnet string - CephHosts []CephHostConfig + CephHosts []installer.CephHostConfig K8sManaged bool K8sAPIServer string @@ -66,7 +67,7 @@ type InitInstallConfigOpts struct { ClusterPublicGatewayIPs []string MetalLBEnabled bool - MetalLBPools []MetalLBPool + MetalLBPools []installer.MetalLBPool CodesphereDomain string CodespherePublicIP string @@ -82,347 +83,7 @@ type InitInstallConfigOpts struct { CodesphereWorkspacePlanMaxReplica int } -type CephHostConfig struct { - Hostname string - IPAddress string - IsMaster bool -} - -type MetalLBPool struct { - Name string - IPAddresses []string -} - -type Gen0Config struct { - 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"` - 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"` -} - -type PostgresPrimaryConfig struct { - SSLConfig SSLConfig `yaml:"sslConfig"` - IP string `yaml:"ip"` - Hostname string `yaml:"hostname"` -} - -type PostgresReplicaConfig struct { - IP string `yaml:"ip"` - Name string `yaml:"name"` - SSLConfig SSLConfig `yaml:"sslConfig"` -} - -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"` -} - -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"` -} - -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"` -} - -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 { - 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"` -} - -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 ImageRef struct { - BomRef string `yaml:"bomRef"` -} - -type DeployConfig struct { - Images map[string]DeployImage `yaml:"images"` -} - -type DeployImage struct { - Name string `yaml:"name"` - SupportedUntil string `yaml:"supportedUntil"` - Flavors map[string]DeployFlavor `yaml:"flavors"` -} - -type DeployFlavor struct { - Image ImageRef `yaml:"image"` - Pool map[int]int `yaml:"pool"` -} - -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 Gen0Vault 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"` -} +// only other function would be CreateConfig(cg *ConfigGenerator) error func (c *InitInstallConfigCmd) RunE(_ *cobra.Command, args []string) error { if c.Opts.ValidateOnly { @@ -439,31 +100,74 @@ func (c *InitInstallConfigCmd) RunE(_ *cobra.Command, args []string) error { fmt.Println("This wizard will help you create config.yaml and prod.vault.yaml for Codesphere installation.") fmt.Println() - if err := c.collectConfiguration(); err != nil { - return fmt.Errorf("failed to collect configuration: %w", err) + // to function toconfigopts + configOpts := &installer.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, } - var generatedSecrets *GeneratedSecrets - if c.Opts.GenerateKeys { - fmt.Println("\nGenerating SSH keys and certificates...") - var err error - generatedSecrets, err = c.generateSecrets() - if err != nil { - return fmt.Errorf("failed to generate secrets: %w", err) - } - fmt.Println("Keys and certificates generated successfully") + config, err := c.Generator.CollectConfiguration(configOpts) + if err != nil { + return fmt.Errorf("failed to collect configuration: %w", err) } - config := c.buildGen0Config(generatedSecrets) - configYAML, err := yaml.Marshal(config) + configYAML, err := installer.MarshalConfig(config) if err != nil { return fmt.Errorf("failed to marshal config.yaml: %w", err) } if c.Opts.WithComments { - configYAML = c.addConfigComments(configYAML) + configYAML = installer.AddConfigComments(configYAML) } + // todo function fo config and vault configFile, err := c.FileWriter.Create(c.Opts.ConfigFile) if err != nil { return fmt.Errorf("failed to create config file: %w", err) @@ -476,14 +180,14 @@ func (c *InitInstallConfigCmd) RunE(_ *cobra.Command, args []string) error { fmt.Printf("\nConfiguration file created: %s\n", c.Opts.ConfigFile) - vault := c.buildGen0Vault(generatedSecrets) - vaultYAML, err := yaml.Marshal(vault) + vault := config.ExtractVault() + vaultYAML, err := installer.MarshalVault(vault) if err != nil { return fmt.Errorf("failed to marshal vault.yaml: %w", err) } if c.Opts.WithComments { - vaultYAML = c.addVaultComments(vaultYAML) + vaultYAML = installer.AddVaultComments(vaultYAML) } vaultFile, err := c.FileWriter.Create(c.Opts.VaultFile) @@ -502,10 +206,8 @@ func (c *InitInstallConfigCmd) RunE(_ *cobra.Command, args []string) error { fmt.Println("Configuration files successfully generated!") fmt.Println(strings.Repeat("=", 70)) - if c.Opts.GenerateKeys { - 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("\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") @@ -514,7 +216,7 @@ func (c *InitInstallConfigCmd) RunE(_ *cobra.Command, args []string) error { 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 Gen0 installer with these configuration files") + fmt.Println("5. Run the Codesphere installer with these configuration files") fmt.Println() return nil @@ -531,7 +233,7 @@ func (c *InitInstallConfigCmd) applyProfile() error { c.Opts.PostgresPrimaryIP = "127.0.0.1" c.Opts.PostgresPrimaryHost = "localhost" c.Opts.CephSubnet = "127.0.0.1/32" - c.Opts.CephHosts = []CephHostConfig{{Hostname: "localhost", IPAddress: "127.0.0.1", IsMaster: true}} + c.Opts.CephHosts = []installer.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"} @@ -549,7 +251,7 @@ func (c *InitInstallConfigCmd) applyProfile() error { c.Opts.CodesphereHostingPlanTempStorage = 1024 c.Opts.CodesphereWorkspacePlanName = "Standard Developer" c.Opts.CodesphereWorkspacePlanMaxReplica = 3 - c.Opts.NonInteractive = true + c.Opts.Interactive = false c.Opts.GenerateKeys = true c.Opts.SecretsBaseDir = "/root/secrets" fmt.Println("Applied 'dev' profile: single-node development setup") @@ -565,7 +267,7 @@ func (c *InitInstallConfigCmd) applyProfile() error { c.Opts.PostgresReplicaIP = "10.50.0.3" c.Opts.PostgresReplicaName = "replica1" c.Opts.CephSubnet = "10.53.101.0/24" - c.Opts.CephHosts = []CephHostConfig{ + c.Opts.CephHosts = []installer.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}, @@ -600,7 +302,7 @@ func (c *InitInstallConfigCmd) applyProfile() error { c.Opts.PostgresPrimaryIP = "127.0.0.1" c.Opts.PostgresPrimaryHost = "localhost" c.Opts.CephSubnet = "127.0.0.1/32" - c.Opts.CephHosts = []CephHostConfig{{Hostname: "localhost", IPAddress: "127.0.0.1", IsMaster: true}} + c.Opts.CephHosts = []installer.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"} @@ -618,7 +320,7 @@ func (c *InitInstallConfigCmd) applyProfile() error { c.Opts.CodesphereHostingPlanTempStorage = 1024 c.Opts.CodesphereWorkspacePlanName = "Standard Developer" c.Opts.CodesphereWorkspacePlanMaxReplica = 1 - c.Opts.NonInteractive = true + c.Opts.Interactive = false c.Opts.GenerateKeys = true c.Opts.SecretsBaseDir = "/root/secrets" fmt.Println("Applied 'minimal' profile: minimal single-node setup") @@ -640,46 +342,17 @@ func (c *InitInstallConfigCmd) validateConfig() error { } defer util.CloseFileIgnoreError(configFile) - var config Gen0Config - decoder := yaml.NewDecoder(configFile) - if err := decoder.Decode(&config); err != nil { - return fmt.Errorf("failed to parse config.yaml: %w", err) - } - - 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)) - } + configData, err := io.ReadAll(configFile) + if err != nil { + return fmt.Errorf("failed to read config file: %w", err) } - 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") - } + config, err := installer.UnmarshalConfig(configData) + if err != nil { + return fmt.Errorf("failed to parse config.yaml: %w", err) } - if config.Codesphere.Domain == "" { - errors = append(errors, "Codesphere domain is required") - } + errors := installer.ValidateConfig(config) if c.Opts.VaultFile != "" { fmt.Printf("Reading vault file: %s\n", c.Opts.VaultFile) @@ -689,20 +362,16 @@ func (c *InitInstallConfigCmd) validateConfig() error { } else { defer util.CloseFileIgnoreError(vaultFile) - var vault Gen0Vault - vaultDecoder := yaml.NewDecoder(vaultFile) - if err := vaultDecoder.Decode(&vault); err != nil { - errors = append(errors, fmt.Sprintf("failed to parse vault.yaml: %v", err)) + vaultData, err := io.ReadAll(vaultFile) + if err != nil { + errors = append(errors, fmt.Sprintf("failed to read vault.yaml: %v", err)) } else { - 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)) - } + 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...) } } } @@ -720,27 +389,26 @@ func (c *InitInstallConfigCmd) validateConfig() error { return nil } -func isValidIP(ip string) bool { - return net.ParseIP(ip) != nil -} - func AddInitInstallConfigCmd(init *cobra.Command, opts *GlobalOptions) { c := InitInstallConfigCmd{ cmd: &cobra.Command{ Use: "install-config", - Short: "Initialize Codesphere Gen0 installer configuration files", - Long: io.Long(`Initialize config.yaml and prod.vault.yaml for the Codesphere Gen0 installer. + 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", []io.Example{ - {Cmd: "-c config.yaml -v prod.vault.yaml", Desc: "Create Gen0 config files interactively"}, + 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"}, @@ -750,13 +418,15 @@ func AddInitInstallConfigCmd(init *cobra.Command, opts *GlobalOptions) { FileWriter: util.NewFilesystemWriter(), } + c.Generator = installer.NewConfigGenerator(c.Opts.Interactive) + 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.NonInteractive, "non-interactive", false, "Use default values without prompting") + 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") @@ -774,6 +444,10 @@ func AddInitInstallConfigCmd(init *cobra.Command, opts *GlobalOptions) { util.MarkFlagRequired(c.cmd, "config") util.MarkFlagRequired(c.cmd, "vault") + c.cmd.PreRun = func(cmd *cobra.Command, args []string) { + c.Generator.Interactive = c.Opts.Interactive + } + c.cmd.RunE = c.RunE init.AddCommand(c.cmd) } diff --git a/cli/cmd/init_install_config_builders.go b/cli/cmd/init_install_config_builders.go deleted file mode 100644 index e775cc1d..00000000 --- a/cli/cmd/init_install_config_builders.go +++ /dev/null @@ -1,647 +0,0 @@ -// Copyright (c) Codesphere Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "bufio" - "fmt" - "os" - "strconv" - "strings" -) - -func (c *InitInstallConfigCmd) collectConfiguration() error { - fmt.Println("=== Datacenter Configuration ===") - if c.Opts.DatacenterID == 0 { - c.Opts.DatacenterID = c.promptInt("Datacenter ID", 1) - } - if c.Opts.DatacenterName == "" { - c.Opts.DatacenterName = c.promptString("Datacenter name", "main") - } - if c.Opts.DatacenterCity == "" { - c.Opts.DatacenterCity = c.promptString("Datacenter city", "Karlsruhe") - } - if c.Opts.DatacenterCountryCode == "" { - c.Opts.DatacenterCountryCode = c.promptString("Country code", "DE") - } - - if c.Opts.SecretsBaseDir == "" { - c.Opts.SecretsBaseDir = c.promptString("Secrets base directory", "/root/secrets") - } - - fmt.Println("\n=== Container Registry Configuration ===") - if c.Opts.RegistryServer == "" { - c.Opts.RegistryServer = c.promptString("Container registry server (e.g., ghcr.io, leave empty to skip)", "") - } - if c.Opts.RegistryServer != "" { - if !c.Opts.NonInteractive { - c.Opts.RegistryReplaceImages = c.promptBool("Replace images in BOM", true) - c.Opts.RegistryLoadContainerImgs = c.promptBool("Load container images from installer", false) - } - } - - fmt.Println("\n=== PostgreSQL Configuration ===") - if c.Opts.PostgresMode == "" { - c.Opts.PostgresMode = c.promptChoice("PostgreSQL setup", []string{"install", "external"}, "install") - } - - if c.Opts.PostgresMode == "install" { - if c.Opts.PostgresPrimaryIP == "" { - c.Opts.PostgresPrimaryIP = c.promptString("Primary PostgreSQL server IP", "10.50.0.2") - } - if c.Opts.PostgresPrimaryHost == "" { - c.Opts.PostgresPrimaryHost = c.promptString("Primary PostgreSQL hostname", "pg-primary-node") - } - if !c.Opts.NonInteractive { - hasReplica := c.promptBool("Configure PostgreSQL replica", true) - if hasReplica { - if c.Opts.PostgresReplicaIP == "" { - c.Opts.PostgresReplicaIP = c.promptString("Replica PostgreSQL server IP", "10.50.0.3") - } - if c.Opts.PostgresReplicaName == "" { - c.Opts.PostgresReplicaName = c.promptString("Replica name (lowercase alphanumeric + underscore only)", "replica1") - } - } - } - c.Opts.GenerateKeys = true - } else { - if c.Opts.PostgresExternal == "" { - c.Opts.PostgresExternal = c.promptString("External PostgreSQL server address", "postgres.example.com:5432") - } - } - - fmt.Println("\n=== Ceph Configuration ===") - if c.Opts.CephSubnet == "" { - c.Opts.CephSubnet = c.promptString("Ceph nodes subnet (CIDR)", "10.53.101.0/24") - } - - if len(c.Opts.CephHosts) == 0 { - numHosts := c.promptInt("Number of Ceph hosts", 3) - c.Opts.CephHosts = make([]CephHostConfig, numHosts) - for i := 0; i < numHosts; i++ { - fmt.Printf("\nCeph Host %d:\n", i+1) - c.Opts.CephHosts[i].Hostname = c.promptString(" Hostname (as shown by 'hostname' command)", fmt.Sprintf("ceph-node-%d", i)) - c.Opts.CephHosts[i].IPAddress = c.promptString(" IP address", fmt.Sprintf("10.53.101.%d", i+2)) - c.Opts.CephHosts[i].IsMaster = (i == 0) - } - } - c.Opts.GenerateKeys = true - - fmt.Println("\n=== Kubernetes Configuration ===") - if !c.Opts.NonInteractive { - c.Opts.K8sManaged = c.promptBool("Use Codesphere-managed Kubernetes (k0s)", true) - } - - if c.Opts.K8sManaged { - if c.Opts.K8sAPIServer == "" { - c.Opts.K8sAPIServer = c.promptString("Kubernetes API server host (LB/DNS/IP)", "10.50.0.2") - } - if len(c.Opts.K8sControlPlane) == 0 { - c.Opts.K8sControlPlane = c.promptStringSlice("Control plane IP addresses (comma-separated)", []string{"10.50.0.2"}) - } - if len(c.Opts.K8sWorkers) == 0 { - c.Opts.K8sWorkers = c.promptStringSlice("Worker node IP addresses (comma-separated)", []string{"10.50.0.2", "10.50.0.3", "10.50.0.4"}) - } - } else { - if c.Opts.K8sPodCIDR == "" { - c.Opts.K8sPodCIDR = c.promptString("Pod CIDR of external cluster", "100.96.0.0/11") - } - if c.Opts.K8sServiceCIDR == "" { - c.Opts.K8sServiceCIDR = c.promptString("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") - } - - fmt.Println("\n=== Cluster Gateway Configuration ===") - if c.Opts.ClusterGatewayType == "" { - c.Opts.ClusterGatewayType = c.promptChoice("Gateway service type", []string{"LoadBalancer", "ExternalIP"}, "LoadBalancer") - } - if c.Opts.ClusterGatewayType == "ExternalIP" && len(c.Opts.ClusterGatewayIPs) == 0 { - c.Opts.ClusterGatewayIPs = c.promptStringSlice("Gateway IP addresses (comma-separated)", []string{"10.51.0.2", "10.51.0.3"}) - } - - if c.Opts.ClusterPublicGatewayType == "" { - c.Opts.ClusterPublicGatewayType = c.promptChoice("Public gateway service type", []string{"LoadBalancer", "ExternalIP"}, "LoadBalancer") - } - if c.Opts.ClusterPublicGatewayType == "ExternalIP" && len(c.Opts.ClusterPublicGatewayIPs) == 0 { - c.Opts.ClusterPublicGatewayIPs = c.promptStringSlice("Public gateway IP addresses (comma-separated)", []string{"10.52.0.2", "10.52.0.3"}) - } - - fmt.Println("\n=== MetalLB Configuration (Optional) ===") - if !c.Opts.NonInteractive { - c.Opts.MetalLBEnabled = c.promptBool("Enable MetalLB", false) - if c.Opts.MetalLBEnabled { - numPools := c.promptInt("Number of MetalLB IP pools", 1) - c.Opts.MetalLBPools = make([]MetalLBPool, numPools) - for i := 0; i < numPools; i++ { - fmt.Printf("\nMetalLB Pool %d:\n", i+1) - c.Opts.MetalLBPools[i].Name = c.promptString(" Pool name", fmt.Sprintf("pool-%d", i+1)) - c.Opts.MetalLBPools[i].IPAddresses = c.promptStringSlice(" IP addresses/ranges (comma-separated)", []string{"10.10.10.100-10.10.10.200"}) - } - } - } - - fmt.Println("\n=== Codesphere Application Configuration ===") - if c.Opts.CodesphereDomain == "" { - c.Opts.CodesphereDomain = c.promptString("Main Codesphere domain", "codesphere.yourcompany.com") - } - if c.Opts.CodesphereWorkspaceBaseDomain == "" { - c.Opts.CodesphereWorkspaceBaseDomain = c.promptString("Workspace base domain (*.domain should point to public gateway)", "ws.yourcompany.com") - } - if c.Opts.CodespherePublicIP == "" { - c.Opts.CodespherePublicIP = c.promptString("Primary public IP for workspaces", "") - } - if c.Opts.CodesphereCustomDomainBaseDomain == "" { - c.Opts.CodesphereCustomDomainBaseDomain = c.promptString("Custom domain CNAME base", "custom.yourcompany.com") - } - if len(c.Opts.CodesphereDNSServers) == 0 { - c.Opts.CodesphereDNSServers = c.promptStringSlice("DNS servers (comma-separated)", []string{"1.1.1.1", "8.8.8.8"}) - } - - fmt.Println("\n=== Workspace Plans Configuration ===") - if c.Opts.CodesphereWorkspaceImageBomRef == "" { - c.Opts.CodesphereWorkspaceImageBomRef = c.promptString("Workspace agent image BOM reference", "workspace-agent-24.04") - } - if c.Opts.CodesphereHostingPlanCPU == 0 { - c.Opts.CodesphereHostingPlanCPU = c.promptInt("Hosting plan CPU (tenths, e.g., 10 = 1 core)", 10) - } - if c.Opts.CodesphereHostingPlanMemory == 0 { - c.Opts.CodesphereHostingPlanMemory = c.promptInt("Hosting plan memory (MB)", 2048) - } - if c.Opts.CodesphereHostingPlanStorage == 0 { - c.Opts.CodesphereHostingPlanStorage = c.promptInt("Hosting plan storage (MB)", 20480) - } - if c.Opts.CodesphereHostingPlanTempStorage == 0 { - c.Opts.CodesphereHostingPlanTempStorage = c.promptInt("Hosting plan temp storage (MB)", 1024) - } - if c.Opts.CodesphereWorkspacePlanName == "" { - c.Opts.CodesphereWorkspacePlanName = c.promptString("Workspace plan name", "Standard Developer") - } - if c.Opts.CodesphereWorkspacePlanMaxReplica == 0 { - c.Opts.CodesphereWorkspacePlanMaxReplica = c.promptInt("Max replicas per workspace", 3) - } - - return nil -} - -func (c *InitInstallConfigCmd) buildGen0Config(secrets *GeneratedSecrets) *Gen0Config { - config := &Gen0Config{ - DataCenter: DataCenterConfig{ - ID: c.Opts.DatacenterID, - Name: c.Opts.DatacenterName, - City: c.Opts.DatacenterCity, - CountryCode: c.Opts.DatacenterCountryCode, - }, - Secrets: SecretsConfig{ - BaseDir: c.Opts.SecretsBaseDir, - }, - Ceph: CephConfig{ - CephAdmSSHKey: CephSSHKey{ - PublicKey: secrets.CephSSHPublicKey, - }, - NodesSubnet: c.Opts.CephSubnet, - Hosts: make([]CephHost, len(c.Opts.CephHosts)), - OSDs: []CephOSD{ - { - SpecID: "default", - Placement: CephPlacement{ - HostPattern: "*", - }, - DataDevices: CephDataDevices{ - Size: "240G:300G", - Limit: 1, - }, - DBDevices: CephDBDevices{ - Size: "120G:150G", - Limit: 1, - }, - }, - }, - }, - Cluster: ClusterConfig{ - Certificates: ClusterCertificates{ - CA: CAConfig{ - Algorithm: "RSA", - KeySizeBits: 2048, - CertPem: secrets.IngressCACert, - }, - }, - Gateway: GatewayConfig{ - ServiceType: c.Opts.ClusterGatewayType, - IPAddresses: c.Opts.ClusterGatewayIPs, - }, - PublicGateway: GatewayConfig{ - ServiceType: c.Opts.ClusterPublicGatewayType, - IPAddresses: c.Opts.ClusterPublicGatewayIPs, - }, - }, - Codesphere: CodesphereConfig{ - Domain: c.Opts.CodesphereDomain, - WorkspaceHostingBaseDomain: c.Opts.CodesphereWorkspaceBaseDomain, - PublicIP: c.Opts.CodespherePublicIP, - CustomDomains: CustomDomainsConfig{ - CNameBaseDomain: c.Opts.CodesphereCustomDomainBaseDomain, - }, - DNSServers: c.Opts.CodesphereDNSServers, - Experiments: []string{}, - DeployConfig: DeployConfig{ - Images: map[string]DeployImage{ - "ubuntu-24.04": { - Name: "Ubuntu 24.04", - SupportedUntil: "2028-05-31", - Flavors: map[string]DeployFlavor{ - "default": { - Image: ImageRef{ - BomRef: c.Opts.CodesphereWorkspaceImageBomRef, - }, - Pool: map[int]int{1: 1}, - }, - }, - }, - }, - }, - Plans: PlansConfig{ - HostingPlans: map[int]HostingPlan{ - 1: { - CPUTenth: c.Opts.CodesphereHostingPlanCPU, - GPUParts: 0, - MemoryMb: c.Opts.CodesphereHostingPlanMemory, - StorageMb: c.Opts.CodesphereHostingPlanStorage, - TempStorageMb: c.Opts.CodesphereHostingPlanTempStorage, - }, - }, - WorkspacePlans: map[int]WorkspacePlan{ - 1: { - Name: c.Opts.CodesphereWorkspacePlanName, - HostingPlanID: 1, - MaxReplicas: c.Opts.CodesphereWorkspacePlanMaxReplica, - OnDemand: true, - }, - }, - }, - }, - } - - for i, host := range c.Opts.CephHosts { - config.Ceph.Hosts[i] = CephHost(host) - } - - if c.Opts.RegistryServer != "" { - config.Registry = &RegistryConfig{ - Server: c.Opts.RegistryServer, - ReplaceImagesInBom: c.Opts.RegistryReplaceImages, - LoadContainerImages: c.Opts.RegistryLoadContainerImgs, - } - } - - if c.Opts.PostgresMode == "install" { - config.Postgres = PostgresConfig{ - CACertPem: secrets.PostgresCACert, - Primary: &PostgresPrimaryConfig{ - SSLConfig: SSLConfig{ - ServerCertPem: secrets.PostgresPrimaryCert, - }, - IP: c.Opts.PostgresPrimaryIP, - Hostname: c.Opts.PostgresPrimaryHost, - }, - } - if c.Opts.PostgresReplicaIP != "" { - config.Postgres.Replica = &PostgresReplicaConfig{ - IP: c.Opts.PostgresReplicaIP, - Name: c.Opts.PostgresReplicaName, - SSLConfig: SSLConfig{ - ServerCertPem: secrets.PostgresReplicaCert, - }, - } - } - } else { - config.Postgres = PostgresConfig{ - ServerAddress: c.Opts.PostgresExternal, - } - } - - config.Kubernetes = KubernetesConfig{ - ManagedByCodesphere: c.Opts.K8sManaged, - } - if c.Opts.K8sManaged { - config.Kubernetes.APIServerHost = c.Opts.K8sAPIServer - config.Kubernetes.ControlPlanes = make([]K8sNode, len(c.Opts.K8sControlPlane)) - for i, ip := range c.Opts.K8sControlPlane { - config.Kubernetes.ControlPlanes[i] = K8sNode{IPAddress: ip} - } - config.Kubernetes.Workers = make([]K8sNode, len(c.Opts.K8sWorkers)) - for i, ip := range c.Opts.K8sWorkers { - config.Kubernetes.Workers[i] = K8sNode{IPAddress: ip} - } - } else { - config.Kubernetes.PodCIDR = c.Opts.K8sPodCIDR - config.Kubernetes.ServiceCIDR = c.Opts.K8sServiceCIDR - } - - if c.Opts.MetalLBEnabled { - config.MetalLB = &MetalLBConfig{ - Enabled: true, - Pools: make([]MetalLBPoolDef, len(c.Opts.MetalLBPools)), - } - for i, pool := range c.Opts.MetalLBPools { - config.MetalLB.Pools[i] = MetalLBPoolDef(pool) - } - } - - config.ManagedServiceBackends = &ManagedServiceBackendsConfig{ - Postgres: make(map[string]interface{}), - } - - return config -} - -func (c *InitInstallConfigCmd) buildGen0Vault(secrets *GeneratedSecrets) *Gen0Vault { - vault := &Gen0Vault{ - Secrets: []SecretEntry{ - { - Name: "cephSshPrivateKey", - File: &SecretFile{ - Name: "id_rsa", - Content: secrets.CephSSHPrivateKey, - }, - }, - { - Name: "selfSignedCaKeyPem", - File: &SecretFile{ - Name: "key.pem", - Content: secrets.IngressCAKey, - }, - }, - { - Name: "domainAuthPrivateKey", - File: &SecretFile{ - Name: "key.pem", - Content: secrets.DomainAuthPrivateKey, - }, - }, - { - Name: "domainAuthPublicKey", - File: &SecretFile{ - Name: "key.pem", - Content: secrets.DomainAuthPublicKey, - }, - }, - }, - } - - if c.Opts.PostgresMode == "install" { - vault.Secrets = append(vault.Secrets, - SecretEntry{ - Name: "postgresPassword", - Fields: &SecretFields{ - Password: secrets.PostgresAdminPassword, - }, - }, - SecretEntry{ - Name: "postgresReplicaPassword", - Fields: &SecretFields{ - Password: secrets.PostgresReplicaPassword, - }, - }, - SecretEntry{ - Name: "postgresPrimaryServerKeyPem", - File: &SecretFile{ - Name: "primary.key", - Content: secrets.PostgresPrimaryKey, - }, - }, - ) - if c.Opts.PostgresReplicaIP != "" { - vault.Secrets = append(vault.Secrets, SecretEntry{ - Name: "postgresReplicaServerKeyPem", - File: &SecretFile{ - Name: "replica.key", - Content: secrets.PostgresReplicaKey, - }, - }) - } - } - - 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", - }, - }) - vault.Secrets = append(vault.Secrets, SecretEntry{ - Name: fmt.Sprintf("postgresPassword%s", capitalize(service)), - Fields: &SecretFields{ - Password: secrets.PostgresUserPasswords[service], - }, - }) - } - - vault.Secrets = append(vault.Secrets, SecretEntry{ - Name: "managedServiceSecrets", - Fields: &SecretFields{ - Password: "[]", - }, - }) - - if c.Opts.RegistryServer != "" { - vault.Secrets = append(vault.Secrets, - SecretEntry{ - Name: "registryUsername", - Fields: &SecretFields{ - Password: "YOUR_REGISTRY_USERNAME", - }, - }, - SecretEntry{ - Name: "registryPassword", - Fields: &SecretFields{ - Password: "YOUR_REGISTRY_PASSWORD", - }, - }, - ) - } - - if !c.Opts.K8sManaged { - 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", - }, - }) - } - - return vault -} - -func (c *InitInstallConfigCmd) promptString(prompt, defaultValue string) string { - if c.Opts.NonInteractive { - return defaultValue - } - - reader := bufio.NewReader(os.Stdin) - if defaultValue != "" { - fmt.Printf("%s (default: %s): ", prompt, defaultValue) - } else { - fmt.Printf("%s: ", prompt) - } - - input, _ := reader.ReadString('\n') - input = strings.TrimSpace(input) - - if input == "" { - return defaultValue - } - return input -} - -func (c *InitInstallConfigCmd) promptInt(prompt string, defaultValue int) int { - if c.Opts.NonInteractive { - return defaultValue - } - - reader := bufio.NewReader(os.Stdin) - fmt.Printf("%s (default: %d): ", prompt, defaultValue) - - input, _ := 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 (c *InitInstallConfigCmd) promptStringSlice(prompt string, defaultValue []string) []string { - if c.Opts.NonInteractive { - return defaultValue - } - - reader := bufio.NewReader(os.Stdin) - defaultStr := strings.Join(defaultValue, ", ") - if defaultStr != "" { - fmt.Printf("%s (default: %s): ", prompt, defaultStr) - } else { - fmt.Printf("%s: ", prompt) - } - - input, _ := 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 (c *InitInstallConfigCmd) promptBool(prompt string, defaultValue bool) bool { - if c.Opts.NonInteractive { - return defaultValue - } - - reader := bufio.NewReader(os.Stdin) - defaultStr := "n" - if defaultValue { - defaultStr = "y" - } - fmt.Printf("%s (y/n, default: %s): ", prompt, defaultStr) - - input, _ := reader.ReadString('\n') - input = strings.TrimSpace(strings.ToLower(input)) - - if input == "" { - return defaultValue - } - - return input == "y" || input == "yes" -} - -func (c *InitInstallConfigCmd) promptChoice(prompt string, choices []string, defaultValue string) string { - if c.Opts.NonInteractive { - return defaultValue - } - - reader := bufio.NewReader(os.Stdin) - fmt.Printf("%s [%s] (default: %s): ", prompt, strings.Join(choices, "/"), defaultValue) - - input, _ := 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 -} - -func capitalize(s string) string { - if s == "" { - return "" - } - s = strings.ReplaceAll(s, "_", "") - return strings.ToUpper(s[:1]) + s[1:] -} - -func (c *InitInstallConfigCmd) addConfigComments(yamlData []byte) []byte { - header := `# Codesphere Gen0 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 (c *InitInstallConfigCmd) addVaultComments(yamlData []byte) []byte { - header := `# Codesphere Gen0 Installer Secrets -# Generated by OMS CLI -# -# IMPORTANT: This file contains sensitive information! -# -# Before storing or transmitting this file: -# 1. Install SOPS and Age: brew install sops age -# 2. Generate an Age keypair: age-keygen -o age_key.txt -# 3. Encrypt this file: -# age-keygen -y age_key.txt # Get public key -# sops --encrypt --age --in-place prod.vault.yaml -# -# Keep the Age private key (age_key.txt) extremely secure! -# -# To edit the encrypted file later: -# export SOPS_AGE_KEY_FILE=/path/to/age_key.txt -# sops prod.vault.yaml - -` - return append([]byte(header), yamlData...) -} diff --git a/cli/cmd/init_install_config_builders_test.go b/cli/cmd/init_install_config_builders_test.go deleted file mode 100644 index 9a6c4d4d..00000000 --- a/cli/cmd/init_install_config_builders_test.go +++ /dev/null @@ -1,306 +0,0 @@ -// Copyright (c) Codesphere Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "strings" - "testing" -) - -func TestBuildGen0Config(t *testing.T) { - cmd := &InitInstallConfigCmd{ - Opts: &InitInstallConfigOpts{ - DatacenterID: 1, - DatacenterName: "test-dc", - DatacenterCity: "Berlin", - DatacenterCountryCode: "DE", - SecretsBaseDir: "/root/secrets", - CephSubnet: "10.53.101.0/24", - CephHosts: []CephHostConfig{{Hostname: "ceph-1", IPAddress: "10.53.101.2", IsMaster: true}}, - PostgresMode: "install", - PostgresPrimaryIP: "10.50.0.2", - PostgresPrimaryHost: "pg-primary", - PostgresReplicaIP: "10.50.0.3", - PostgresReplicaName: "replica1", - K8sManaged: true, - K8sAPIServer: "10.50.0.2", - K8sControlPlane: []string{"10.50.0.2"}, - K8sWorkers: []string{"10.50.0.3"}, - ClusterGatewayType: "LoadBalancer", - ClusterPublicGatewayType: "LoadBalancer", - CodesphereDomain: "codesphere.example.com", - CodesphereWorkspaceBaseDomain: "ws.example.com", - CodespherePublicIP: "1.2.3.4", - CodesphereCustomDomainBaseDomain: "custom.example.com", - CodesphereDNSServers: []string{"8.8.8.8"}, - CodesphereWorkspaceImageBomRef: "workspace-agent-24.04", - CodesphereHostingPlanCPU: 10, - CodesphereHostingPlanMemory: 2048, - CodesphereHostingPlanStorage: 20480, - CodesphereHostingPlanTempStorage: 1024, - CodesphereWorkspacePlanName: "Standard", - CodesphereWorkspacePlanMaxReplica: 3, - }, - } - - secrets := &GeneratedSecrets{ - CephSSHPublicKey: "ssh-rsa TEST", - IngressCACert: "-----BEGIN CERTIFICATE-----\nTEST\n-----END CERTIFICATE-----", - PostgresCACert: "-----BEGIN CERTIFICATE-----\nPG-CA\n-----END CERTIFICATE-----", - PostgresPrimaryCert: "-----BEGIN CERTIFICATE-----\nPG-PRIMARY\n-----END CERTIFICATE-----", - PostgresReplicaCert: "-----BEGIN CERTIFICATE-----\nPG-REPLICA\n-----END CERTIFICATE-----", - PostgresUserPasswords: map[string]string{"auth": "password123"}, - } - - config := cmd.buildGen0Config(secrets) - - if config.DataCenter.ID != 1 { - t.Errorf("DataCenter.ID = %d, want 1", config.DataCenter.ID) - } - if config.DataCenter.Name != "test-dc" { - t.Errorf("DataCenter.Name = %s, want test-dc", config.DataCenter.Name) - } - - if len(config.Ceph.Hosts) != 1 { - t.Errorf("len(Ceph.Hosts) = %d, want 1", len(config.Ceph.Hosts)) - } - if config.Ceph.Hosts[0].Hostname != "ceph-1" { - t.Errorf("Ceph.Hosts[0].Hostname = %s, want ceph-1", config.Ceph.Hosts[0].Hostname) - } - if config.Ceph.CephAdmSSHKey.PublicKey != "ssh-rsa TEST" { - t.Error("Ceph SSH public key not set correctly") - } - - if config.Postgres.CACertPem == "" { - t.Error("Postgres.CACertPem should not be empty") - } - if config.Postgres.Primary == nil { - t.Fatal("Postgres.Primary should not be nil") - } - if config.Postgres.Primary.IP != "10.50.0.2" { - t.Errorf("Postgres.Primary.IP = %s, want 10.50.0.2", config.Postgres.Primary.IP) - } - if config.Postgres.Replica == nil { - t.Fatal("Postgres.Replica should not be nil") - } - - if !config.Kubernetes.ManagedByCodesphere { - t.Error("Kubernetes.ManagedByCodesphere should be true") - } - if len(config.Kubernetes.ControlPlanes) != 1 { - t.Errorf("len(Kubernetes.ControlPlanes) = %d, want 1", len(config.Kubernetes.ControlPlanes)) - } - - if config.Codesphere.Domain != "codesphere.example.com" { - t.Errorf("Codesphere.Domain = %s, want codesphere.example.com", config.Codesphere.Domain) - } - if len(config.Codesphere.Plans.HostingPlans) != 1 { - t.Error("Should have one hosting plan") - } - if len(config.Codesphere.Plans.WorkspacePlans) != 1 { - t.Error("Should have one workspace plan") - } -} - -func TestBuildGen0ConfigExternalPostgres(t *testing.T) { - cmd := &InitInstallConfigCmd{ - Opts: &InitInstallConfigOpts{ - DatacenterID: 1, - DatacenterName: "test-dc", - DatacenterCity: "Berlin", - DatacenterCountryCode: "DE", - SecretsBaseDir: "/root/secrets", - CephSubnet: "10.53.101.0/24", - CephHosts: []CephHostConfig{{Hostname: "ceph-1", IPAddress: "10.53.101.2", IsMaster: true}}, - PostgresMode: "external", - PostgresExternal: "postgres.example.com:5432", - K8sManaged: false, - K8sPodCIDR: "100.96.0.0/11", - K8sServiceCIDR: "100.64.0.0/13", - ClusterGatewayType: "LoadBalancer", - ClusterPublicGatewayType: "LoadBalancer", - CodesphereDomain: "codesphere.example.com", - CodesphereWorkspaceBaseDomain: "ws.example.com", - CodesphereCustomDomainBaseDomain: "custom.example.com", - CodesphereDNSServers: []string{"8.8.8.8"}, - CodesphereWorkspaceImageBomRef: "workspace-agent-24.04", - CodesphereHostingPlanCPU: 10, - CodesphereHostingPlanMemory: 2048, - CodesphereHostingPlanStorage: 20480, - CodesphereHostingPlanTempStorage: 1024, - CodesphereWorkspacePlanName: "Standard", - CodesphereWorkspacePlanMaxReplica: 3, - }, - } - - secrets := &GeneratedSecrets{ - CephSSHPublicKey: "ssh-rsa TEST", - IngressCACert: "-----BEGIN CERTIFICATE-----\nTEST\n-----END CERTIFICATE-----", - } - - config := cmd.buildGen0Config(secrets) - - if config.Postgres.ServerAddress != "postgres.example.com:5432" { - t.Errorf("Postgres.ServerAddress = %s, want postgres.example.com:5432", config.Postgres.ServerAddress) - } - if config.Postgres.Primary != nil { - t.Error("Postgres.Primary should be nil for external mode") - } - - if config.Kubernetes.ManagedByCodesphere { - t.Error("Kubernetes.ManagedByCodesphere should be false") - } - if config.Kubernetes.PodCIDR != "100.96.0.0/11" { - t.Errorf("Kubernetes.PodCIDR = %s, want 100.96.0.0/11", config.Kubernetes.PodCIDR) - } -} - -func TestBuildGen0Vault(t *testing.T) { - cmd := &InitInstallConfigCmd{ - Opts: &InitInstallConfigOpts{ - PostgresMode: "install", - PostgresReplicaIP: "10.50.0.3", - RegistryServer: "ghcr.io", - K8sManaged: true, - }, - } - - secrets := &GeneratedSecrets{ - CephSSHPrivateKey: "-----BEGIN RSA PRIVATE KEY-----\nCEPH\n-----END RSA PRIVATE KEY-----", - IngressCAKey: "-----BEGIN RSA PRIVATE KEY-----\nCA\n-----END RSA PRIVATE KEY-----", - DomainAuthPrivateKey: "-----BEGIN EC PRIVATE KEY-----\nDOMAIN\n-----END EC PRIVATE KEY-----", - DomainAuthPublicKey: "-----BEGIN PUBLIC KEY-----\nDOMAIN-PUB\n-----END PUBLIC KEY-----", - PostgresAdminPassword: "admin123", - PostgresReplicaPassword: "replica123", - PostgresPrimaryKey: "-----BEGIN RSA PRIVATE KEY-----\nPG-PRIMARY\n-----END RSA PRIVATE KEY-----", - PostgresReplicaKey: "-----BEGIN RSA PRIVATE KEY-----\nPG-REPLICA\n-----END RSA PRIVATE KEY-----", - PostgresUserPasswords: map[string]string{ - "auth": "auth-pass", - "deployment": "deployment-pass", - "ide": "ide-pass", - }, - } - - vault := cmd.buildGen0Vault(secrets) - - expectedSecrets := map[string]bool{ - "cephSshPrivateKey": false, - "selfSignedCaKeyPem": false, - "domainAuthPrivateKey": false, - "domainAuthPublicKey": false, - "postgresPassword": false, - "postgresReplicaPassword": false, - "postgresPrimaryServerKeyPem": false, - "postgresReplicaServerKeyPem": false, - "registryUsername": false, - "registryPassword": false, - "managedServiceSecrets": false, - } - - for _, secret := range vault.Secrets { - if _, exists := expectedSecrets[secret.Name]; exists { - expectedSecrets[secret.Name] = true - } - } - - for name, found := range expectedSecrets { - if !found && strings.HasPrefix(name, "postgres") { - t.Errorf("Expected postgres secret %s not found in vault", name) - } - } - - foundCephSSH := false - foundIngressCA := false - for _, secret := range vault.Secrets { - if secret.Name == "cephSshPrivateKey" { - foundCephSSH = true - if secret.File == nil { - t.Error("cephSshPrivateKey should have a file") - } else if secret.File.Name != "id_rsa" { - t.Errorf("cephSshPrivateKey file name = %s, want id_rsa", secret.File.Name) - } - } - if secret.Name == "selfSignedCaKeyPem" { - foundIngressCA = true - if secret.File == nil { - t.Error("selfSignedCaKeyPem should have a file") - } - } - } - - if !foundCephSSH { - t.Error("cephSshPrivateKey not found in vault") - } - if !foundIngressCA { - t.Error("selfSignedCaKeyPem not found in vault") - } -} - -func TestBuildGen0VaultExternalK8s(t *testing.T) { - cmd := &InitInstallConfigCmd{ - Opts: &InitInstallConfigOpts{ - PostgresMode: "external", - K8sManaged: false, - }, - } - - secrets := &GeneratedSecrets{ - CephSSHPrivateKey: "-----BEGIN RSA PRIVATE KEY-----\nCEPH\n-----END RSA PRIVATE KEY-----", - IngressCAKey: "-----BEGIN RSA PRIVATE KEY-----\nCA\n-----END RSA PRIVATE KEY-----", - DomainAuthPrivateKey: "-----BEGIN EC PRIVATE KEY-----\nDOMAIN\n-----END EC PRIVATE KEY-----", - DomainAuthPublicKey: "-----BEGIN PUBLIC KEY-----\nDOMAIN-PUB\n-----END PUBLIC KEY-----", - } - - vault := cmd.buildGen0Vault(secrets) - - foundKubeConfig := false - for _, secret := range vault.Secrets { - if secret.Name == "kubeConfig" { - foundKubeConfig = true - if secret.File == nil { - t.Error("kubeConfig should have a file") - } - } - } - - if !foundKubeConfig { - t.Error("kubeConfig not found in vault for external Kubernetes") - } -} - -func TestAddConfigComments(t *testing.T) { - cmd := &InitInstallConfigCmd{} - yamlData := []byte("test: value\n") - - result := cmd.addConfigComments(yamlData) - resultStr := string(result) - - if !strings.Contains(resultStr, "Codesphere Gen0 Installer Configuration") { - t.Error("Config comments should contain header text") - } - if !strings.Contains(resultStr, "test: value") { - t.Error("Config comments should preserve original YAML") - } -} - -func TestAddVaultComments(t *testing.T) { - cmd := &InitInstallConfigCmd{} - yamlData := []byte("secrets:\n - name: test\n") - - result := cmd.addVaultComments(yamlData) - resultStr := string(result) - - if !strings.Contains(resultStr, "Codesphere Gen0 Installer Secrets") { - t.Error("Vault comments should contain header text") - } - if !strings.Contains(resultStr, "IMPORTANT") { - t.Error("Vault comments should contain security warning") - } - if !strings.Contains(resultStr, "SOPS") { - t.Error("Vault comments should mention SOPS") - } - if !strings.Contains(resultStr, "secrets:") { - t.Error("Vault comments should preserve original YAML") - } -} diff --git a/cli/cmd/init_install_config_gen0_test.go b/cli/cmd/init_install_config_gen0_test.go deleted file mode 100644 index 65e582c6..00000000 --- a/cli/cmd/init_install_config_gen0_test.go +++ /dev/null @@ -1,355 +0,0 @@ -// Copyright (c) Codesphere Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/rsa" - "crypto/x509" - "encoding/pem" - "strings" - "testing" - - "golang.org/x/crypto/ssh" -) - -func TestGenerateSSHKeyPair(t *testing.T) { - privKey, pubKey, err := generateSSHKeyPair() - if err != nil { - t.Fatalf("generateSSHKeyPair failed: %v", err) - } - - if !strings.HasPrefix(privKey, "-----BEGIN RSA PRIVATE KEY-----") { - t.Error("Private key should be in PEM format") - } - - _, _, _, _, err = ssh.ParseAuthorizedKey([]byte(pubKey)) - if err != nil { - t.Errorf("Failed to parse SSH public key: %v", err) - } - - block, _ := pem.Decode([]byte(privKey)) - if block == nil { - t.Fatal("Failed to decode private key PEM") - } - if block.Type != "RSA PRIVATE KEY" { - t.Errorf("Expected RSA PRIVATE KEY, got %s", block.Type) - } -} - -func TestGenerateCA(t *testing.T) { - keyPEM, certPEM, err := generateCA("Test CA", "DE", "Berlin", "TestOrg") - if err != nil { - t.Fatalf("generateCA failed: %v", err) - } - - if !strings.HasPrefix(keyPEM, "-----BEGIN RSA PRIVATE KEY-----") { - t.Error("CA key should be in PEM format") - } - - if !strings.HasPrefix(certPEM, "-----BEGIN CERTIFICATE-----") { - t.Error("CA cert should be in PEM format") - } - - certBlock, _ := pem.Decode([]byte(certPEM)) - if certBlock == nil { - t.Fatal("Failed to decode certificate PEM") - } - - cert, err := x509.ParseCertificate(certBlock.Bytes) - if err != nil { - t.Fatalf("Failed to parse certificate: %v", err) - } - - if !cert.IsCA { - t.Error("Certificate should be a CA") - } - if cert.Subject.CommonName != "Test CA" { - t.Errorf("Expected CN 'Test CA', got '%s'", cert.Subject.CommonName) - } - if len(cert.Subject.Country) == 0 || cert.Subject.Country[0] != "DE" { - t.Error("Expected country DE") - } - if len(cert.Subject.Locality) == 0 || cert.Subject.Locality[0] != "Berlin" { - t.Error("Expected locality Berlin") - } - if len(cert.Subject.Organization) == 0 || cert.Subject.Organization[0] != "TestOrg" { - t.Error("Expected organization TestOrg") - } -} - -func TestGenerateServerCertificate(t *testing.T) { - caKeyPEM, caCertPEM, err := generateCA("Test CA", "DE", "Berlin", "TestOrg") - if err != nil { - t.Fatalf("Failed to generate CA: %v", err) - } - - serverKeyPEM, serverCertPEM, err := generateServerCertificate( - caKeyPEM, - caCertPEM, - "test-server", - []string{"192.168.1.1", "10.0.0.1"}, - ) - if err != nil { - t.Fatalf("generateServerCertificate failed: %v", err) - } - - if !strings.HasPrefix(serverKeyPEM, "-----BEGIN RSA PRIVATE KEY-----") { - t.Error("Server key should be in PEM format") - } - - if !strings.HasPrefix(serverCertPEM, "-----BEGIN CERTIFICATE-----") { - t.Error("Server cert should be in PEM format") - } - - certBlock, _ := pem.Decode([]byte(serverCertPEM)) - if certBlock == nil { - t.Fatal("Failed to decode server certificate PEM") - } - - cert, err := x509.ParseCertificate(certBlock.Bytes) - if err != nil { - t.Fatalf("Failed to parse server certificate: %v", err) - } - - if cert.Subject.CommonName != "test-server" { - t.Errorf("Expected CN 'test-server', got '%s'", cert.Subject.CommonName) - } - - if len(cert.IPAddresses) != 2 { - t.Errorf("Expected 2 IP addresses, got %d", len(cert.IPAddresses)) - } -} - -func TestGenerateECDSAKeyPair(t *testing.T) { - privKey, pubKey, err := generateECDSAKeyPair() - if err != nil { - t.Fatalf("generateECDSAKeyPair failed: %v", err) - } - - if !strings.HasPrefix(privKey, "-----BEGIN EC PRIVATE KEY-----") { - t.Error("Private key should be in EC PEM format") - } - - if !strings.HasPrefix(pubKey, "-----BEGIN PUBLIC KEY-----") { - t.Error("Public key should be in PEM format") - } - - privBlock, _ := pem.Decode([]byte(privKey)) - if privBlock == nil { - t.Fatal("Failed to decode private key PEM") - } - if privBlock.Type != "EC PRIVATE KEY" { - t.Errorf("Expected EC PRIVATE KEY, got %s", privBlock.Type) - } - - pubBlock, _ := pem.Decode([]byte(pubKey)) - if pubBlock == nil { - t.Fatal("Failed to decode public key PEM") - } - if pubBlock.Type != "PUBLIC KEY" { - t.Errorf("Expected PUBLIC KEY, got %s", pubBlock.Type) - } -} - -func TestGeneratePassword(t *testing.T) { - password := generatePassword(20) - - if len(password) != 20 { - t.Errorf("Expected password length 20, got %d", len(password)) - } - - password2 := generatePassword(20) - if password == password2 { - t.Error("Generated passwords should be different") - } -} - -func TestParseIP(t *testing.T) { - tests := []struct { - name string - ip string - wantNil bool - }{ - {"valid IPv4", "192.168.1.1", false}, - {"valid IPv6", "2001:db8::1", false}, - {"invalid IP", "not-an-ip", true}, - {"empty string", "", true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := parseIP(tt.ip) - if tt.wantNil && result != nil { - t.Errorf("parseIP(%s) should return nil, got %v", tt.ip, result) - } - if !tt.wantNil && result == nil { - t.Errorf("parseIP(%s) should not return nil", tt.ip) - } - }) - } -} - -func TestParseCAKeyAndCert(t *testing.T) { - caKeyPEM, caCertPEM, err := generateCA("Test CA", "DE", "Berlin", "TestOrg") - if err != nil { - t.Fatalf("Failed to generate CA: %v", err) - } - - caKey, caCert, err := parseCAKeyAndCert(caKeyPEM, caCertPEM) - if err != nil { - t.Fatalf("parseCAKeyAndCert failed: %v", err) - } - if caKey == nil { - t.Error("CA key should not be nil") - } - if caCert == nil { - t.Error("CA cert should not be nil") - } - - _, _, err = parseCAKeyAndCert("invalid-pem", caCertPEM) - if err == nil { - t.Error("Expected error for invalid key PEM") - } - - _, _, err = parseCAKeyAndCert(caKeyPEM, "invalid-pem") - if err == nil { - t.Error("Expected error for invalid cert PEM") - } -} - -func TestEncodePEMKey(t *testing.T) { - tests := []struct { - name string - keyType string - wantErr bool - }{ - {"RSA key", "RSA", false}, - {"EC key", "EC", false}, - {"invalid type", "INVALID", true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var key interface{} - var err error - - switch tt.keyType { - case "RSA": - key, err = generateTestRSAKey() - case "EC": - key, err = generateTestECKey() - default: - key = "invalid-key" - } - - if err != nil && !tt.wantErr { - t.Fatalf("Failed to generate test key: %v", err) - } - - result, err := encodePEMKey(key, tt.keyType) - if (err != nil) != tt.wantErr { - t.Errorf("encodePEMKey() error = %v, wantErr %v", err, tt.wantErr) - } - - if !tt.wantErr && !strings.Contains(result, "-----BEGIN") { - t.Error("Result should be in PEM format") - } - }) - } -} - -func TestEncodePEMCert(t *testing.T) { - _, certPEM, err := generateCA("Test CA", "DE", "Berlin", "TestOrg") - if err != nil { - t.Fatalf("Failed to generate CA: %v", err) - } - - certBlock, _ := pem.Decode([]byte(certPEM)) - if certBlock == nil { - t.Fatal("Failed to decode certificate PEM") - } - - result := encodePEMCert(certBlock.Bytes) - - if !strings.HasPrefix(result, "-----BEGIN CERTIFICATE-----") { - t.Error("Result should be in PEM format") - } -} - -func TestGenerateSecrets(t *testing.T) { - cmd := &InitInstallConfigCmd{ - Opts: &InitInstallConfigOpts{ - PostgresMode: "install", - PostgresPrimaryHost: "pg-primary", - PostgresPrimaryIP: "10.0.0.1", - PostgresReplicaIP: "10.0.0.2", - PostgresReplicaName: "replica1", - }, - } - - secrets, err := cmd.generateSecrets() - if err != nil { - t.Fatalf("generateSecrets failed: %v", err) - } - - if secrets.CephSSHPrivateKey == "" { - t.Error("Ceph SSH private key should not be empty") - } - if secrets.CephSSHPublicKey == "" { - t.Error("Ceph SSH public key should not be empty") - } - - if secrets.IngressCAKey == "" { - t.Error("Ingress CA key should not be empty") - } - if secrets.IngressCACert == "" { - t.Error("Ingress CA cert should not be empty") - } - - if secrets.DomainAuthPrivateKey == "" { - t.Error("Domain auth private key should not be empty") - } - if secrets.DomainAuthPublicKey == "" { - t.Error("Domain auth public key should not be empty") - } - - if secrets.PostgresCACert == "" { - t.Error("PostgreSQL CA cert should not be empty") - } - if secrets.PostgresPrimaryCert == "" { - t.Error("PostgreSQL primary cert should not be empty") - } - if secrets.PostgresReplicaCert == "" { - t.Error("PostgreSQL replica cert should not be empty") - } - - if secrets.PostgresAdminPassword == "" { - t.Error("PostgreSQL admin password should not be empty") - } - if secrets.PostgresReplicaPassword == "" { - t.Error("PostgreSQL replica password should not be empty") - } - - expectedServices := []string{"auth", "deployment", "ide", "marketplace", "payment", "public_api", "team", "workspace"} - for _, service := range expectedServices { - if _, ok := secrets.PostgresUserPasswords[service]; !ok { - t.Errorf("Missing password for service: %s", service) - } - } -} - -func generateTestRSAKey() (interface{}, error) { - rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - return nil, err - } - return rsaKey, nil -} - -func generateTestECKey() (interface{}, error) { - return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) -} diff --git a/cli/cmd/init_install_config_test.go b/cli/cmd/init_install_config_test.go index 4c9b3b62..bfc8cd63 100644 --- a/cli/cmd/init_install_config_test.go +++ b/cli/cmd/init_install_config_test.go @@ -5,111 +5,73 @@ package cmd import ( "os" - "strings" - "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" "github.com/codesphere-cloud/oms/internal/util" ) -func TestIsValidIP(t *testing.T) { - tests := []struct { - name string - ip string - valid bool - }{ - {"valid IPv4", "192.168.1.1", true}, - {"valid IPv6", "2001:db8::1", true}, - {"invalid IP", "not-an-ip", false}, - {"empty string", "", false}, - {"partial IP", "192.168", false}, - {"localhost", "127.0.0.1", true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := isValidIP(tt.ip) - if result != tt.valid { - t.Errorf("isValidIP(%q) = %v, want %v", tt.ip, result, tt.valid) - } - }) - } -} - -func TestApplyProfile(t *testing.T) { - tests := []struct { - name string - profile string - wantErr bool - checkDatacenter string - }{ - {"dev profile", "dev", false, "dev"}, - {"development profile", "development", false, "dev"}, - {"prod profile", "prod", false, "production"}, - {"production profile", "production", false, "production"}, - {"minimal profile", "minimal", false, "minimal"}, - {"invalid profile", "invalid", true, ""}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cmd := &InitInstallConfigCmd{ +var _ = Describe("ApplyProfile", func() { + DescribeTable("profile application", + func(profile string, wantErr bool, checkDatacenter string) { + c := &InitInstallConfigCmd{ Opts: &InitInstallConfigOpts{ - Profile: tt.profile, + Profile: profile, }, } - err := cmd.applyProfile() - if (err != nil) != tt.wantErr { - t.Errorf("applyProfile() error = %v, wantErr %v", err, tt.wantErr) + 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, ""), + ) - if !tt.wantErr && cmd.Opts.DatacenterName != tt.checkDatacenter { - t.Errorf("DatacenterName = %s, want %s", cmd.Opts.DatacenterName, tt.checkDatacenter) + Context("dev profile details", func() { + It("sets correct dev profile configuration", func() { + c := &InitInstallConfigCmd{ + Opts: &InitInstallConfigOpts{ + Profile: "dev", + }, } - }) - } -} - -func TestApplyDevProfile(t *testing.T) { - cmd := &InitInstallConfigCmd{ - Opts: &InitInstallConfigOpts{ - Profile: "dev", - }, - } - err := cmd.applyProfile() - if err != nil { - t.Fatalf("applyProfile failed: %v", err) - } + 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()) + }) + }) +}) - if cmd.Opts.DatacenterID != 1 { - t.Errorf("DatacenterID = %d, want 1", cmd.Opts.DatacenterID) - } - if cmd.Opts.DatacenterName != "dev" { - t.Errorf("DatacenterName = %s, want dev", cmd.Opts.DatacenterName) - } - if cmd.Opts.PostgresMode != "install" { - t.Errorf("PostgresMode = %s, want install", cmd.Opts.PostgresMode) - } - if cmd.Opts.K8sManaged != true { - t.Error("K8sManaged should be true for dev profile") - } -} +var _ = Describe("ValidateConfig", func() { + var ( + configFile *os.File + vaultFile *os.File + validConfig string + validVault string + ) -func TestValidateConfig(t *testing.T) { - configFile, err := os.CreateTemp("", "config-*.yaml") - if err != nil { - t.Fatalf("Failed to create temp config file: %v", err) - } - defer func() { _ = os.Remove(configFile.Name()) }() + BeforeEach(func() { + var err error + configFile, err = os.CreateTemp("", "config-*.yaml") + Expect(err).NotTo(HaveOccurred()) - vaultFile, err := os.CreateTemp("", "vault-*.yaml") - if err != nil { - t.Fatalf("Failed to create temp vault file: %v", err) - } - defer func() { _ = os.Remove(vaultFile.Name()) }() + vaultFile, err = os.CreateTemp("", "vault-*.yaml") + Expect(err).NotTo(HaveOccurred()) - validConfig := `dataCenter: + validConfig = `dataCenter: id: 1 name: test city: Berlin @@ -177,7 +139,7 @@ codesphere: onDemand: true ` - validVault := `secrets: + validVault = `secrets: - name: cephSshPrivateKey file: name: id_rsa @@ -195,44 +157,42 @@ codesphere: name: key.pem content: "-----BEGIN PUBLIC KEY-----\nDOMAIN-PUB\n-----END PUBLIC KEY-----" ` + }) - if _, err := configFile.WriteString(validConfig); err != nil { - t.Fatalf("Failed to write config: %v", err) - } - if err := configFile.Close(); err != nil { - t.Fatalf("Failed to close config file: %v", err) - } + AfterEach(func() { + _ = os.Remove(configFile.Name()) + _ = os.Remove(vaultFile.Name()) + }) - if _, err := vaultFile.WriteString(validVault); err != nil { - t.Fatalf("Failed to write vault: %v", err) - } - if err := vaultFile.Close(); err != nil { - t.Fatalf("Failed to close vault file: %v", err) - } + Context("valid configuration", func() { + It("validates successfully", func() { + _, err := configFile.WriteString(validConfig) + Expect(err).NotTo(HaveOccurred()) + err = configFile.Close() + Expect(err).NotTo(HaveOccurred()) - cmd := &InitInstallConfigCmd{ - Opts: &InitInstallConfigOpts{ - ConfigFile: configFile.Name(), - VaultFile: vaultFile.Name(), - ValidateOnly: true, - }, - FileWriter: util.NewFilesystemWriter(), - } + _, err = vaultFile.WriteString(validVault) + Expect(err).NotTo(HaveOccurred()) + err = vaultFile.Close() + Expect(err).NotTo(HaveOccurred()) - err = cmd.validateConfig() - if err != nil { - t.Errorf("validateConfig() failed for valid config: %v", err) - } -} + c := &InitInstallConfigCmd{ + Opts: &InitInstallConfigOpts{ + ConfigFile: configFile.Name(), + VaultFile: vaultFile.Name(), + ValidateOnly: true, + }, + FileWriter: util.NewFilesystemWriter(), + } -func TestValidateConfigInvalidDatacenter(t *testing.T) { - configFile, err := os.CreateTemp("", "config-*.yaml") - if err != nil { - t.Fatalf("Failed to create temp config file: %v", err) - } - defer func() { _ = os.Remove(configFile.Name()) }() + err = c.validateConfig() + Expect(err).NotTo(HaveOccurred()) + }) + }) - invalidConfig := `dataCenter: + Context("invalid datacenter", func() { + It("fails validation", func() { + invalidConfig := `dataCenter: id: 0 name: "" secrets: @@ -262,35 +222,27 @@ codesphere: workspacePlans: {} ` - if _, err := configFile.WriteString(invalidConfig); err != nil { - t.Fatalf("Failed to write config: %v", err) - } - if err := configFile.Close(); err != nil { - t.Fatalf("Failed to close config file: %v", err) - } + _, err := configFile.WriteString(invalidConfig) + Expect(err).NotTo(HaveOccurred()) + err = configFile.Close() + Expect(err).NotTo(HaveOccurred()) - cmd := &InitInstallConfigCmd{ - Opts: &InitInstallConfigOpts{ - ConfigFile: configFile.Name(), - ValidateOnly: true, - }, - FileWriter: util.NewFilesystemWriter(), - } - - err = cmd.validateConfig() - if err == nil { - t.Error("validateConfig() should fail for invalid config") - } -} + c := &InitInstallConfigCmd{ + Opts: &InitInstallConfigOpts{ + ConfigFile: configFile.Name(), + ValidateOnly: true, + }, + FileWriter: util.NewFilesystemWriter(), + } -func TestValidateConfigInvalidIP(t *testing.T) { - configFile, err := os.CreateTemp("", "config-*.yaml") - if err != nil { - t.Fatalf("Failed to create temp config file: %v", err) - } - defer func() { _ = os.Remove(configFile.Name()) }() + err = c.validateConfig() + Expect(err).To(HaveOccurred()) + }) + }) - configWithInvalidIP := `dataCenter: + Context("invalid IP address", func() { + It("fails validation", func() { + configWithInvalidIP := `dataCenter: id: 1 name: test city: Berlin @@ -331,26 +283,21 @@ codesphere: workspacePlans: {} ` - if _, err := configFile.WriteString(configWithInvalidIP); err != nil { - t.Fatalf("Failed to write config: %v", err) - } - if err := configFile.Close(); err != nil { - t.Fatalf("Failed to close config file: %v", err) - } + _, err := configFile.WriteString(configWithInvalidIP) + Expect(err).NotTo(HaveOccurred()) + err = configFile.Close() + Expect(err).NotTo(HaveOccurred()) - cmd := &InitInstallConfigCmd{ - Opts: &InitInstallConfigOpts{ - ConfigFile: configFile.Name(), - ValidateOnly: true, - }, - FileWriter: util.NewFilesystemWriter(), - } + c := &InitInstallConfigCmd{ + Opts: &InitInstallConfigOpts{ + ConfigFile: configFile.Name(), + ValidateOnly: true, + }, + FileWriter: util.NewFilesystemWriter(), + } - err = cmd.validateConfig() - if err == nil { - t.Error("validateConfig() should fail for invalid IP address") - } - if err != nil && !strings.Contains(err.Error(), "invalid") { - t.Logf("Got error: %v", err) - } -} + err = c.validateConfig() + Expect(err).To(HaveOccurred()) + }) + }) +}) diff --git a/internal/installer/config_generator.go b/internal/installer/config_generator.go new file mode 100644 index 00000000..f6b75707 --- /dev/null +++ b/internal/installer/config_generator.go @@ -0,0 +1,1165 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "fmt" + "net" + "strings" + + "gopkg.in/yaml.v3" +) + +type ConfigGenerator struct { + Interactive bool + configOpts *ConfigOptions + config *InstallConfig +} + +func NewConfigGenerator(interactive bool) *ConfigGenerator { + return &ConfigGenerator{ + Interactive: interactive, + } +} + +type InstallConfig struct { + 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"` + 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 { + 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 ImageRef struct { + BomRef string `yaml:"bomRef"` +} + +type DeployConfig struct { + Images map[string]DeployImage `yaml:"images"` +} + +type DeployImage struct { + Name string `yaml:"name"` + SupportedUntil string `yaml:"supportedUntil"` + Flavors map[string]DeployFlavor `yaml:"flavors"` +} + +type DeployFlavor struct { + Image ImageRef `yaml:"image"` + Pool map[int]int `yaml:"pool"` +} + +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 +} + +// CollectConfiguration gathers all configuration needed for Codesphere installation. +// It uses the provided ConfigOptions to pre-fill values, and falls back to +// interactive prompts (if enabled) or defaults when options are missing. +// Returns a complete InstallConfig with generated secrets stored in yaml:"-" fields. +func (g *ConfigGenerator) CollectConfiguration(opts *ConfigOptions) (*InstallConfig, error) { + // 1. collectConfig, 2. convertConfi, 3. generateSecrets, 4. writeYamlFile, 5 writeVaultFile + + prompter := NewPrompter(g.Interactive) + + fmt.Println("=== Datacenter Configuration ===") + dcID := opts.DatacenterID + if dcID == 0 { + dcID = prompter.Int("Datacenter ID", 1) + } + dcName := opts.DatacenterName + if dcName == "" { + dcName = prompter.String("Datacenter name", "main") + } + dcCity := opts.DatacenterCity + if dcCity == "" { + dcCity = prompter.String("Datacenter city", "Karlsruhe") + } + dcCountry := opts.DatacenterCountryCode + if dcCountry == "" { + dcCountry = prompter.String("Country code", "DE") + } + + secretsBaseDir := opts.SecretsBaseDir + if secretsBaseDir == "" { + secretsBaseDir = prompter.String("Secrets base directory", "/root/secrets") + } + + fmt.Println("\n=== Container Registry Configuration ===") + registryServer := opts.RegistryServer + if registryServer == "" { + registryServer = prompter.String("Container registry server (e.g., ghcr.io, leave empty to skip)", "") + } + var registryConfig *RegistryConfig + if registryServer != "" { + registryConfig = &RegistryConfig{ + Server: registryServer, + ReplaceImagesInBom: opts.RegistryReplaceImages, + LoadContainerImages: opts.RegistryLoadContainerImgs, + } + if g.Interactive { + registryConfig.ReplaceImagesInBom = prompter.Bool("Replace images in BOM", true) + registryConfig.LoadContainerImages = prompter.Bool("Load container images from installer", false) + } + } + + fmt.Println("\n=== PostgreSQL Configuration ===") + pgMode := opts.PostgresMode + if pgMode == "" { + pgMode = prompter.Choice("PostgreSQL setup", []string{"install", "external"}, "install") + } + + var postgresConfig PostgresConfig + if pgMode == "install" { + pgPrimaryIP := opts.PostgresPrimaryIP + if pgPrimaryIP == "" { + pgPrimaryIP = prompter.String("Primary PostgreSQL server IP", "10.50.0.2") + } + pgPrimaryHost := opts.PostgresPrimaryHost + if pgPrimaryHost == "" { + pgPrimaryHost = prompter.String("Primary PostgreSQL hostname", "pg-primary-node") + } + + fmt.Println("Generating PostgreSQL certificates and passwords...") + pgCAKey, pgCACert, err := GenerateCA("PostgreSQL CA", "DE", "Karlsruhe", "Codesphere") + if err != nil { + return nil, fmt.Errorf("failed to generate PostgreSQL CA: %w", err) + } + pgPrimaryKey, pgPrimaryCert, err := GenerateServerCertificate(pgCAKey, pgCACert, pgPrimaryHost, []string{pgPrimaryIP}) + if err != nil { + return nil, fmt.Errorf("failed to generate primary PostgreSQL certificate: %w", err) + } + adminPassword := GeneratePassword(32) + replicaPassword := GeneratePassword(32) + + postgresConfig = PostgresConfig{ + CACertPem: pgCACert, + caCertPrivateKey: pgCAKey, + adminPassword: adminPassword, + replicaPassword: replicaPassword, + Primary: &PostgresPrimaryConfig{ + SSLConfig: SSLConfig{ + ServerCertPem: pgPrimaryCert, + }, + IP: pgPrimaryIP, + Hostname: pgPrimaryHost, + privateKey: pgPrimaryKey, + }, + } + + pgReplicaIP := opts.PostgresReplicaIP + pgReplicaName := opts.PostgresReplicaName + if g.Interactive { + hasReplica := prompter.Bool("Configure PostgreSQL replica", true) + if hasReplica { + if pgReplicaIP == "" { + pgReplicaIP = prompter.String("Replica PostgreSQL server IP", "10.50.0.3") + } + if pgReplicaName == "" { + pgReplicaName = prompter.String("Replica name (lowercase alphanumeric + underscore only)", "replica1") + } + } + } + + if pgReplicaIP != "" { + pgReplicaKey, pgReplicaCert, err := GenerateServerCertificate(pgCAKey, pgCACert, pgReplicaName, []string{pgReplicaIP}) + if err != nil { + return nil, fmt.Errorf("failed to generate replica PostgreSQL certificate: %w", err) + } + postgresConfig.Replica = &PostgresReplicaConfig{ + IP: pgReplicaIP, + Name: pgReplicaName, + SSLConfig: SSLConfig{ + ServerCertPem: pgReplicaCert, + }, + privateKey: pgReplicaKey, + } + } + + services := []string{"auth", "deployment", "ide", "marketplace", "payment", "public_api", "team", "workspace"} + postgresConfig.userPasswords = make(map[string]string) + for _, service := range services { + postgresConfig.userPasswords[service] = GeneratePassword(32) + } + } else { + pgExternal := opts.PostgresExternal + if pgExternal == "" { + pgExternal = prompter.String("External PostgreSQL server address", "postgres.example.com:5432") + } + postgresConfig = PostgresConfig{ + ServerAddress: pgExternal, + } + } + + fmt.Println("\n=== Ceph Configuration ===") + cephSubnet := opts.CephSubnet + if cephSubnet == "" { + cephSubnet = prompter.String("Ceph nodes subnet (CIDR)", "10.53.101.0/24") + } + + var cephHosts []CephHost + if len(opts.CephHosts) == 0 { + numHosts := prompter.Int("Number of Ceph hosts", 3) + cephHosts = make([]CephHost, numHosts) + for i := 0; i < numHosts; i++ { + fmt.Printf("\nCeph Host %d:\n", i+1) + cephHosts[i].Hostname = prompter.String(" Hostname (as shown by 'hostname' command)", fmt.Sprintf("ceph-node-%d", i)) + cephHosts[i].IPAddress = prompter.String(" IP address", fmt.Sprintf("10.53.101.%d", i+2)) + cephHosts[i].IsMaster = (i == 0) + } + } else { + cephHosts = make([]CephHost, len(opts.CephHosts)) + for i, host := range opts.CephHosts { + cephHosts[i] = CephHost(host) + } + } + + fmt.Println("Generating Ceph SSH keys...") + cephSSHPub, cephSSHPriv, err := GenerateSSHKeyPair() + if err != nil { + return nil, fmt.Errorf("failed to generate Ceph SSH keys: %w", err) + } + + cephConfig := CephConfig{ + CephAdmSSHKey: CephSSHKey{ + PublicKey: cephSSHPub, + }, + NodesSubnet: cephSubnet, + Hosts: cephHosts, + sshPrivateKey: cephSSHPriv, + OSDs: []CephOSD{ + { + SpecID: "default", + Placement: CephPlacement{ + HostPattern: "*", + }, + DataDevices: CephDataDevices{ + Size: "240G:300G", + Limit: 1, + }, + DBDevices: CephDBDevices{ + Size: "120G:150G", + Limit: 1, + }, + }, + }, + } + + fmt.Println("\n=== Kubernetes Configuration ===") + k8sManaged := opts.K8sManaged + if g.Interactive { + k8sManaged = prompter.Bool("Use Codesphere-managed Kubernetes (k0s)", true) + } + + var k8sConfig KubernetesConfig + k8sConfig.ManagedByCodesphere = k8sManaged + + if k8sManaged { + k8sAPIServer := opts.K8sAPIServer + if k8sAPIServer == "" { + k8sAPIServer = prompter.String("Kubernetes API server host (LB/DNS/IP)", "10.50.0.2") + } + var k8sControlPlane []string + if len(opts.K8sControlPlane) == 0 { + k8sControlPlane = prompter.StringSlice("Control plane IP addresses (comma-separated)", []string{"10.50.0.2"}) + } else { + k8sControlPlane = opts.K8sControlPlane + } + var k8sWorkers []string + if len(opts.K8sWorkers) == 0 { + k8sWorkers = prompter.StringSlice("Worker node IP addresses (comma-separated)", []string{"10.50.0.2", "10.50.0.3", "10.50.0.4"}) + } else { + k8sWorkers = opts.K8sWorkers + } + + k8sConfig.APIServerHost = k8sAPIServer + k8sConfig.ControlPlanes = make([]K8sNode, len(k8sControlPlane)) + for i, ip := range k8sControlPlane { + k8sConfig.ControlPlanes[i] = K8sNode{IPAddress: ip} + } + k8sConfig.Workers = make([]K8sNode, len(k8sWorkers)) + for i, ip := range k8sWorkers { + k8sConfig.Workers[i] = K8sNode{IPAddress: ip} + } + k8sConfig.needsKubeConfig = false + } else { + k8sPodCIDR := opts.K8sPodCIDR + if k8sPodCIDR == "" { + k8sPodCIDR = prompter.String("Pod CIDR of external cluster", "100.96.0.0/11") + } + k8sServiceCIDR := opts.K8sServiceCIDR + if k8sServiceCIDR == "" { + k8sServiceCIDR = prompter.String("Service CIDR of external cluster", "100.64.0.0/13") + } + k8sConfig.PodCIDR = k8sPodCIDR + k8sConfig.ServiceCIDR = k8sServiceCIDR + k8sConfig.needsKubeConfig = true + fmt.Println("Note: You'll need to provide kubeconfig in the vault file for external Kubernetes") + } + + fmt.Println("\n=== Cluster Gateway Configuration ===") + gatewayType := opts.ClusterGatewayType + if gatewayType == "" { + gatewayType = prompter.Choice("Gateway service type", []string{"LoadBalancer", "ExternalIP"}, "LoadBalancer") + } + var gatewayIPs []string + if gatewayType == "ExternalIP" && len(opts.ClusterGatewayIPs) == 0 { + gatewayIPs = prompter.StringSlice("Gateway IP addresses (comma-separated)", []string{"10.51.0.2", "10.51.0.3"}) + } else { + gatewayIPs = opts.ClusterGatewayIPs + } + + publicGatewayType := opts.ClusterPublicGatewayType + if publicGatewayType == "" { + publicGatewayType = prompter.Choice("Public gateway service type", []string{"LoadBalancer", "ExternalIP"}, "LoadBalancer") + } + var publicGatewayIPs []string + if publicGatewayType == "ExternalIP" && len(opts.ClusterPublicGatewayIPs) == 0 { + publicGatewayIPs = prompter.StringSlice("Public gateway IP addresses (comma-separated)", []string{"10.52.0.2", "10.52.0.3"}) + } else { + publicGatewayIPs = opts.ClusterPublicGatewayIPs + } + + fmt.Println("Generating ingress CA certificate...") + ingressCAKey, ingressCACert, err := GenerateCA("Cluster Ingress CA", "DE", "Karlsruhe", "Codesphere") + if err != nil { + return nil, fmt.Errorf("failed to generate ingress CA: %w", err) + } + + clusterConfig := ClusterConfig{ + Certificates: ClusterCertificates{ + CA: CAConfig{ + Algorithm: "RSA", + KeySizeBits: 2048, + CertPem: ingressCACert, + }, + }, + Gateway: GatewayConfig{ + ServiceType: gatewayType, + IPAddresses: gatewayIPs, + }, + PublicGateway: GatewayConfig{ + ServiceType: publicGatewayType, + IPAddresses: publicGatewayIPs, + }, + ingressCAKey: ingressCAKey, + } + + fmt.Println("\n=== MetalLB Configuration (Optional) ===") + var metalLBConfig *MetalLBConfig + if g.Interactive { + metalLBEnabled := prompter.Bool("Enable MetalLB", false) + if metalLBEnabled { + // TODO: configopttoxyaml + numPools := prompter.Int("Number of MetalLB IP pools", 1) + pools := make([]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"}) + pools[i] = MetalLBPoolDef{ + Name: poolName, + IPAddresses: poolIPs, + } + } + metalLBConfig = &MetalLBConfig{ + Enabled: true, + Pools: pools, + } + } + } else if opts.MetalLBEnabled { + pools := make([]MetalLBPoolDef, len(opts.MetalLBPools)) + for i, pool := range opts.MetalLBPools { + pools[i] = MetalLBPoolDef(pool) + } + metalLBConfig = &MetalLBConfig{ + Enabled: true, + Pools: pools, + } + } + + fmt.Println("\n=== Codesphere Application Configuration ===") + codesphereDomain := opts.CodesphereDomain + // todo in opts.CodesphereDomain + if codesphereDomain == "" { + codesphereDomain = prompter.String("Main Codesphere domain", "codesphere.yourcompany.com") + } + workspaceDomain := opts.CodesphereWorkspaceBaseDomain + if workspaceDomain == "" { + workspaceDomain = prompter.String("Workspace base domain (*.domain should point to public gateway)", "ws.yourcompany.com") + } + publicIP := opts.CodespherePublicIP + if publicIP == "" { + publicIP = prompter.String("Primary public IP for workspaces", "") + } + customDomain := opts.CodesphereCustomDomainBaseDomain + if customDomain == "" { + customDomain = prompter.String("Custom domain CNAME base", "custom.yourcompany.com") + } + var dnsServers []string + if len(opts.CodesphereDNSServers) == 0 { + dnsServers = prompter.StringSlice("DNS servers (comma-separated)", []string{"1.1.1.1", "8.8.8.8"}) + } else { + dnsServers = opts.CodesphereDNSServers + } + + fmt.Println("\n=== Workspace Plans Configuration ===") + workspaceImageBomRef := opts.CodesphereWorkspaceImageBomRef + if workspaceImageBomRef == "" { + workspaceImageBomRef = prompter.String("Workspace agent image BOM reference", "workspace-agent-24.04") + } + hostingPlanCPU := opts.CodesphereHostingPlanCPU + if hostingPlanCPU == 0 { + hostingPlanCPU = prompter.Int("Hosting plan CPU (tenths, e.g., 10 = 1 core)", 10) + } + hostingPlanMemory := opts.CodesphereHostingPlanMemory + if hostingPlanMemory == 0 { + hostingPlanMemory = prompter.Int("Hosting plan memory (MB)", 2048) + } + hostingPlanStorage := opts.CodesphereHostingPlanStorage + if hostingPlanStorage == 0 { + hostingPlanStorage = prompter.Int("Hosting plan storage (MB)", 20480) + } + hostingPlanTempStorage := opts.CodesphereHostingPlanTempStorage + if hostingPlanTempStorage == 0 { + hostingPlanTempStorage = prompter.Int("Hosting plan temp storage (MB)", 1024) + } + workspacePlanName := opts.CodesphereWorkspacePlanName + if workspacePlanName == "" { + workspacePlanName = prompter.String("Workspace plan name", "Standard Developer") + } + workspacePlanMaxReplica := opts.CodesphereWorkspacePlanMaxReplica + if workspacePlanMaxReplica == 0 { + workspacePlanMaxReplica = prompter.Int("Max replicas per workspace", 3) + } + + fmt.Println("Generating domain authentication keys...") + domainAuthPub, domainAuthPriv, err := GenerateECDSAKeyPair() + if err != nil { + return nil, fmt.Errorf("failed to generate domain auth keys: %w", err) + } + + codesphereConfig := CodesphereConfig{ + Domain: codesphereDomain, + WorkspaceHostingBaseDomain: workspaceDomain, + PublicIP: publicIP, + CustomDomains: CustomDomainsConfig{ + CNameBaseDomain: customDomain, + }, + DNSServers: dnsServers, + Experiments: []string{}, + domainAuthPrivateKey: domainAuthPriv, + domainAuthPublicKey: domainAuthPub, + DeployConfig: DeployConfig{ + Images: map[string]DeployImage{ + "ubuntu-24.04": { + Name: "Ubuntu 24.04", + SupportedUntil: "2028-05-31", + Flavors: map[string]DeployFlavor{ + "default": { + Image: ImageRef{ + BomRef: workspaceImageBomRef, + }, + Pool: map[int]int{1: 1}, + }, + }, + }, + }, + }, + Plans: PlansConfig{ + HostingPlans: map[int]HostingPlan{ + 1: { + CPUTenth: hostingPlanCPU, + GPUParts: 0, + MemoryMb: hostingPlanMemory, + StorageMb: hostingPlanStorage, + TempStorageMb: hostingPlanTempStorage, + }, + }, + WorkspacePlans: map[int]WorkspacePlan{ + 1: { + Name: workspacePlanName, + HostingPlanID: 1, + MaxReplicas: workspacePlanMaxReplica, + OnDemand: true, + }, + }, + }, + } + + config := &InstallConfig{ + DataCenter: DataCenterConfig{ + ID: dcID, + Name: dcName, + City: dcCity, + CountryCode: dcCountry, + }, + Secrets: SecretsConfig{ + BaseDir: secretsBaseDir, + }, + Registry: registryConfig, + Postgres: postgresConfig, + Ceph: cephConfig, + Kubernetes: k8sConfig, + Cluster: clusterConfig, + MetalLB: metalLBConfig, + Codesphere: codesphereConfig, + ManagedServiceBackends: &ManagedServiceBackendsConfig{ + Postgres: make(map[string]interface{}), + }, + } + + return config, nil +} + +// ExtractVault extracts all sensitive data from InstallConfig into a separate vault structure. +// This separates public configuration from secrets that should be encrypted and stored securely. +func (c *InstallConfig) ExtractVault() *InstallVault { + vault := &InstallVault{ + Secrets: []SecretEntry{}, + } + + 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, + }, + }, + ) + } + + if c.Cluster.ingressCAKey != "" { + vault.Secrets = append(vault.Secrets, SecretEntry{ + Name: "selfSignedCaKeyPem", + File: &SecretFile{ + Name: "key.pem", + Content: c.Cluster.ingressCAKey, + }, + }) + } + + if c.Ceph.sshPrivateKey != "" { + vault.Secrets = append(vault.Secrets, SecretEntry{ + Name: "cephSshPrivateKey", + File: &SecretFile{ + Name: "id_rsa", + Content: c.Ceph.sshPrivateKey, + }, + }) + } + + if c.Postgres.Primary != nil { + 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, + }, + }) + } + } + } + + vault.Secrets = append(vault.Secrets, SecretEntry{ + Name: "managedServiceSecrets", + Fields: &SecretFields{ + Password: "[]", + }, + }) + + if c.Registry != nil { + vault.Secrets = append(vault.Secrets, + SecretEntry{ + Name: "registryUsername", + Fields: &SecretFields{ + Password: "YOUR_REGISTRY_USERNAME", + }, + }, + SecretEntry{ + Name: "registryPassword", + Fields: &SecretFields{ + Password: "YOUR_REGISTRY_PASSWORD", + }, + }, + ) + } + + 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", + }, + }) + } + + return vault +} + +func Capitalize(s string) string { + if s == "" { + return "" + } + s = strings.ReplaceAll(s, "_", "") + return strings.ToUpper(s[:1]) + s[1:] +} + +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 *InstallConfig) []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 *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 *InstallConfig) ([]byte, error) { + return yaml.Marshal(config) +} + +func MarshalVault(vault *InstallVault) ([]byte, error) { + return yaml.Marshal(vault) +} + +func UnmarshalConfig(data []byte) (*InstallConfig, error) { + var config InstallConfig + err := yaml.Unmarshal(data, &config) + return &config, err +} + +func UnmarshalVault(data []byte) (*InstallVault, error) { + var vault InstallVault + err := yaml.Unmarshal(data, &vault) + return &vault, err +} diff --git a/internal/installer/config_generator_test.go b/internal/installer/config_generator_test.go new file mode 100644 index 00000000..ff66a5e7 --- /dev/null +++ b/internal/installer/config_generator_test.go @@ -0,0 +1,216 @@ +// 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("ExtractVault", func() { + It("extracts all secrets from config into vault format", func() { + config := &InstallConfig{ + Postgres: 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: &PostgresPrimaryConfig{ + SSLConfig: 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: &PostgresReplicaConfig{ + IP: "10.50.0.3", + Name: "replica1", + SSLConfig: 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: CephConfig{ + sshPrivateKey: "-----BEGIN RSA PRIVATE KEY-----\nCEPH-SSH\n-----END RSA PRIVATE KEY-----", + }, + Cluster: ClusterConfig{ + ingressCAKey: "-----BEGIN RSA PRIVATE KEY-----\nINGRESS-CA-KEY\n-----END RSA PRIVATE KEY-----", + }, + Codesphere: 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: 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 := &InstallConfig{ + Kubernetes: KubernetesConfig{ + needsKubeConfig: false, + }, + Codesphere: 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 := &InstallConfig{ + Postgres: PostgresConfig{ + Primary: &PostgresPrimaryConfig{}, + userPasswords: userPasswords, + }, + Codesphere: 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) + } + }) +}) + +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/cli/cmd/init_install_config_gen0.go b/internal/installer/crypto.go similarity index 60% rename from cli/cmd/init_install_config_gen0.go rename to internal/installer/crypto.go index 41953417..0fbe829f 100644 --- a/cli/cmd/init_install_config_gen0.go +++ b/internal/installer/crypto.go @@ -1,7 +1,7 @@ // Copyright (c) Codesphere Inc. // SPDX-License-Identifier: Apache-2.0 -package cmd +package installer import ( "crypto/ecdsa" @@ -20,103 +20,7 @@ import ( "golang.org/x/crypto/ssh" ) -type GeneratedSecrets struct { - CephSSHPrivateKey string - CephSSHPublicKey string - - IngressCAKey string - IngressCACert string - - DomainAuthPrivateKey string - DomainAuthPublicKey string - - PostgresCAKey string - PostgresCACert string - PostgresPrimaryKey string - PostgresPrimaryCert string - PostgresReplicaKey string - PostgresReplicaCert string - - PostgresAdminPassword string - PostgresReplicaPassword string - PostgresUserPasswords map[string]string - RegistryUsername string - RegistryPassword string -} - -func (c *InitInstallConfigCmd) generateSecrets() (*GeneratedSecrets, error) { - secrets := &GeneratedSecrets{ - PostgresUserPasswords: make(map[string]string), - } - - cephPrivKey, cephPubKey, err := generateSSHKeyPair() - if err != nil { - return nil, fmt.Errorf("failed to generate Ceph SSH key: %w", err) - } - secrets.CephSSHPrivateKey = cephPrivKey - secrets.CephSSHPublicKey = cephPubKey - - ingressCAKey, ingressCACert, err := generateCA("Codesphere Root CA", "DE", "Karlsruhe", "Codesphere") - if err != nil { - return nil, fmt.Errorf("failed to generate ingress CA: %w", err) - } - secrets.IngressCAKey = ingressCAKey - secrets.IngressCACert = ingressCACert - - domainPrivKey, domainPubKey, err := generateECDSAKeyPair() - if err != nil { - return nil, fmt.Errorf("failed to generate domain auth keys: %w", err) - } - secrets.DomainAuthPrivateKey = domainPrivKey - secrets.DomainAuthPublicKey = domainPubKey - - if c.Opts.PostgresMode == "install" { - pgCAKey, pgCACert, err := generateCA("PostgreSQL CA", "DE", "Karlsruhe", "Codesphere") - if err != nil { - return nil, fmt.Errorf("failed to generate PostgreSQL CA: %w", err) - } - secrets.PostgresCAKey = pgCAKey - secrets.PostgresCACert = pgCACert - - pgPrimaryKey, pgPrimaryCert, err := generateServerCertificate( - pgCAKey, - pgCACert, - c.Opts.PostgresPrimaryHost, - []string{c.Opts.PostgresPrimaryIP}, - ) - if err != nil { - return nil, fmt.Errorf("failed to generate PostgreSQL primary certificate: %w", err) - } - secrets.PostgresPrimaryKey = pgPrimaryKey - secrets.PostgresPrimaryCert = pgPrimaryCert - - if c.Opts.PostgresReplicaIP != "" { - pgReplicaKey, pgReplicaCert, err := generateServerCertificate( - pgCAKey, - pgCACert, - c.Opts.PostgresReplicaName, - []string{c.Opts.PostgresReplicaIP}, - ) - if err != nil { - return nil, fmt.Errorf("failed to generate PostgreSQL replica certificate: %w", err) - } - secrets.PostgresReplicaKey = pgReplicaKey - secrets.PostgresReplicaCert = pgReplicaCert - } - - secrets.PostgresAdminPassword = generatePassword(25) - secrets.PostgresReplicaPassword = generatePassword(25) - } - - services := []string{"auth", "deployment", "ide", "marketplace", "payment", "public_api", "team", "workspace"} - for _, service := range services { - secrets.PostgresUserPasswords[service] = generatePassword(20) - } - - return secrets, nil -} - -func generateSSHKeyPair() (privateKey string, publicKey string, err error) { +func GenerateSSHKeyPair() (privateKey string, publicKey string, err error) { rsaKey, err := rsa.GenerateKey(rand.Reader, 4096) if err != nil { return "", "", err @@ -136,7 +40,7 @@ func generateSSHKeyPair() (privateKey string, publicKey string, err error) { return string(privKeyPEM), pubKeySSH, nil } -func generateCA(cn, country, locality, org string) (keyPEM string, certPEM string, err error) { +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 @@ -175,7 +79,7 @@ func generateCA(cn, country, locality, org string) (keyPEM string, certPEM strin return keyPEM, encodePEMCert(certDER), nil } -func generateServerCertificate(caKeyPEM, caCertPEM, cn string, ipAddresses []string) (keyPEM string, certPEM string, err error) { +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 @@ -205,7 +109,9 @@ func generateServerCertificate(caKeyPEM, caCertPEM, cn string, ipAddresses []str } for _, ip := range ipAddresses { - template.IPAddresses = append(template.IPAddresses, parseIP(ip)) + if parsed := net.ParseIP(ip); parsed != nil { + template.IPAddresses = append(template.IPAddresses, parsed) + } } certDER, err := x509.CreateCertificate(rand.Reader, template, caCert, &serverKey.PublicKey, caKey) @@ -221,7 +127,7 @@ func generateServerCertificate(caKeyPEM, caCertPEM, cn string, ipAddresses []str return keyPEM, encodePEMCert(certDER), nil } -func generateECDSAKeyPair() (privateKey string, publicKey string, err error) { +func GenerateECDSAKeyPair() (privateKey string, publicKey string, err error) { ecKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { return "", "", err @@ -244,7 +150,7 @@ func generateECDSAKeyPair() (privateKey string, publicKey string, err error) { return privKeyPEM, string(pubKeyPEM), nil } -func generatePassword(length int) string { +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()))) @@ -252,10 +158,6 @@ func generatePassword(length int) string { return base64.StdEncoding.EncodeToString(bytes)[:length] } -func parseIP(ip string) net.IP { - return net.ParseIP(ip) -} - func parseCAKeyAndCert(caKeyPEM, caCertPEM string) (*rsa.PrivateKey, *x509.Certificate, error) { caKeyBlock, _ := pem.Decode([]byte(caKeyPEM)) if caKeyBlock == nil { diff --git a/internal/installer/crypto_test.go b/internal/installer/crypto_test.go new file mode 100644 index 00000000..31a68e8b --- /dev/null +++ b/internal/installer/crypto_test.go @@ -0,0 +1,115 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "crypto/x509" + "encoding/pem" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "golang.org/x/crypto/ssh" +) + +func TestInstaller(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Installer Suite") +} + +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/prompt.go b/internal/installer/prompt.go new file mode 100644 index 00000000..91d44103 --- /dev/null +++ b/internal/installer/prompt.go @@ -0,0 +1,145 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "bufio" + "fmt" + "os" + "strconv" + "strings" +) + +type Prompter struct { + reader *bufio.Reader + interactive bool +} + +func NewPrompter(interactive bool) *Prompter { + return &Prompter{ + reader: bufio.NewReader(os.Stdin), + interactive: interactive, + } +} + +func (p *Prompter) String(prompt, defaultValue string) string { + if !p.interactive { + return defaultValue + } + + if defaultValue != "" { + fmt.Printf("%s (default: %s): ", prompt, defaultValue) + } else { + fmt.Printf("%s: ", prompt) + } + + input, _ := p.reader.ReadString('\n') + input = strings.TrimSpace(input) + + if input == "" { + return defaultValue + } + return input +} + +func (p *Prompter) Int(prompt string, defaultValue int) int { + if !p.interactive { + return defaultValue + } + + fmt.Printf("%s (default: %d): ", prompt, defaultValue) + + input, _ := p.reader.ReadString('\n') + input = strings.TrimSpace(input) + + if input == "" { + return defaultValue + } + + value, err := strconv.Atoi(input) + if err != nil { + fmt.Printf("Invalid number, using default: %d\n", defaultValue) + return defaultValue + } + return value +} + +func (p *Prompter) StringSlice(prompt string, defaultValue []string) []string { + if !p.interactive { + return defaultValue + } + + defaultStr := strings.Join(defaultValue, ", ") + if defaultStr != "" { + fmt.Printf("%s (default: %s): ", prompt, defaultStr) + } else { + fmt.Printf("%s: ", prompt) + } + + input, _ := p.reader.ReadString('\n') + input = strings.TrimSpace(input) + + if input == "" { + return defaultValue + } + + parts := strings.Split(input, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed != "" { + result = append(result, trimmed) + } + } + + if len(result) == 0 { + return defaultValue + } + return result +} + +func (p *Prompter) Bool(prompt string, defaultValue bool) bool { + if !p.interactive { + return defaultValue + } + + defaultStr := "n" + if defaultValue { + defaultStr = "y" + } + fmt.Printf("%s (y/n, default: %s): ", prompt, defaultStr) + + input, _ := p.reader.ReadString('\n') + input = strings.TrimSpace(strings.ToLower(input)) + + if input == "" { + return defaultValue + } + + return input == "y" || input == "yes" +} + +func (p *Prompter) Choice(prompt string, choices []string, defaultValue string) string { + if !p.interactive { + return defaultValue + } + + fmt.Printf("%s [%s] (default: %s): ", prompt, strings.Join(choices, "/"), defaultValue) + + input, _ := p.reader.ReadString('\n') + input = strings.TrimSpace(strings.ToLower(input)) + + if input == "" { + return defaultValue + } + + for _, choice := range choices { + if strings.ToLower(choice) == input { + return choice + } + } + + fmt.Printf("Invalid choice, using default: %s\n", defaultValue) + return defaultValue +} diff --git a/internal/installer/prompt_test.go b/internal/installer/prompt_test.go new file mode 100644 index 00000000..6b995cbf --- /dev/null +++ b/internal/installer/prompt_test.go @@ -0,0 +1,305 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "bufio" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Prompter", func() { + Describe("NewPrompter", func() { + It("creates a non-interactive prompter", func() { + p := NewPrompter(false) + Expect(p).NotTo(BeNil()) + Expect(p.interactive).To(BeFalse()) + Expect(p.reader).NotTo(BeNil()) + }) + + It("creates an interactive prompter", func() { + p := NewPrompter(true) + Expect(p).NotTo(BeNil()) + Expect(p.interactive).To(BeTrue()) + Expect(p.reader).NotTo(BeNil()) + }) + }) + + Describe("String", func() { + Context("non-interactive mode", func() { + It("returns default value without prompting", func() { + p := NewPrompter(false) + result := p.String("Enter value", "default") + Expect(result).To(Equal("default")) + }) + + It("returns empty string when no default", func() { + p := NewPrompter(false) + result := p.String("Enter value", "") + Expect(result).To(Equal("")) + }) + }) + + Context("interactive mode", func() { + It("returns user input when provided", func() { + input := "user-value\n" + p := &Prompter{ + reader: bufio.NewReader(strings.NewReader(input)), + interactive: true, + } + result := p.String("Enter value", "default") + Expect(result).To(Equal("user-value")) + }) + + It("returns default when input is empty", func() { + input := "\n" + p := &Prompter{ + reader: bufio.NewReader(strings.NewReader(input)), + interactive: true, + } + result := p.String("Enter value", "default") + Expect(result).To(Equal("default")) + }) + + It("trims whitespace from input", func() { + input := " value with spaces \n" + p := &Prompter{ + reader: bufio.NewReader(strings.NewReader(input)), + interactive: true, + } + result := p.String("Enter value", "default") + Expect(result).To(Equal("value with spaces")) + }) + }) + }) + + Describe("Int", func() { + Context("non-interactive mode", func() { + It("returns default value without prompting", func() { + p := NewPrompter(false) + result := p.Int("Enter number", 42) + Expect(result).To(Equal(42)) + }) + }) + + Context("interactive mode", func() { + It("returns parsed integer when valid input provided", func() { + input := "123\n" + p := &Prompter{ + reader: bufio.NewReader(strings.NewReader(input)), + interactive: true, + } + result := p.Int("Enter number", 42) + Expect(result).To(Equal(123)) + }) + + It("returns default when input is empty", func() { + input := "\n" + p := &Prompter{ + reader: bufio.NewReader(strings.NewReader(input)), + interactive: true, + } + result := p.Int("Enter number", 42) + Expect(result).To(Equal(42)) + }) + + It("returns default when input is invalid", func() { + input := "not-a-number\n" + p := &Prompter{ + reader: bufio.NewReader(strings.NewReader(input)), + interactive: true, + } + result := p.Int("Enter number", 42) + Expect(result).To(Equal(42)) + }) + + It("handles negative numbers", func() { + input := "-100\n" + p := &Prompter{ + reader: bufio.NewReader(strings.NewReader(input)), + interactive: true, + } + result := p.Int("Enter number", 0) + Expect(result).To(Equal(-100)) + }) + }) + }) + + Describe("StringSlice", func() { + Context("non-interactive mode", func() { + It("returns default value without prompting", func() { + p := NewPrompter(false) + defaultVal := []string{"one", "two", "three"} + result := p.StringSlice("Enter values", defaultVal) + Expect(result).To(Equal(defaultVal)) + }) + + It("returns empty slice when no default", func() { + p := NewPrompter(false) + result := p.StringSlice("Enter values", []string{}) + Expect(result).To(Equal([]string{})) + }) + }) + + Context("interactive mode", func() { + It("parses comma-separated values", func() { + input := "one, two, three\n" + p := &Prompter{ + reader: bufio.NewReader(strings.NewReader(input)), + interactive: true, + } + result := p.StringSlice("Enter values", []string{}) + Expect(result).To(Equal([]string{"one", "two", "three"})) + }) + + It("returns default when input is empty", func() { + input := "\n" + defaultVal := []string{"default1", "default2"} + p := &Prompter{ + reader: bufio.NewReader(strings.NewReader(input)), + interactive: true, + } + result := p.StringSlice("Enter values", defaultVal) + Expect(result).To(Equal(defaultVal)) + }) + + It("trims whitespace from each value", func() { + input := " one , two , three \n" + p := &Prompter{ + reader: bufio.NewReader(strings.NewReader(input)), + interactive: true, + } + result := p.StringSlice("Enter values", []string{}) + Expect(result).To(Equal([]string{"one", "two", "three"})) + }) + + It("handles single value", func() { + input := "single\n" + p := &Prompter{ + reader: bufio.NewReader(strings.NewReader(input)), + interactive: true, + } + result := p.StringSlice("Enter values", []string{}) + Expect(result).To(Equal([]string{"single"})) + }) + + It("filters out empty values", func() { + input := "one, , two, , three\n" + p := &Prompter{ + reader: bufio.NewReader(strings.NewReader(input)), + interactive: true, + } + result := p.StringSlice("Enter values", []string{}) + Expect(result).To(Equal([]string{"one", "two", "three"})) + }) + }) + }) + + Describe("Bool", func() { + Context("non-interactive mode", func() { + It("returns default true without prompting", func() { + p := NewPrompter(false) + result := p.Bool("Enable feature", true) + Expect(result).To(BeTrue()) + }) + + It("returns default false without prompting", func() { + p := NewPrompter(false) + result := p.Bool("Enable feature", false) + Expect(result).To(BeFalse()) + }) + }) + + Context("interactive mode", func() { + DescribeTable("boolean parsing", + func(input string, expected bool) { + p := &Prompter{ + reader: bufio.NewReader(strings.NewReader(input + "\n")), + interactive: true, + } + result := p.Bool("Enable feature", false) + Expect(result).To(Equal(expected)) + }, + Entry("'y' returns true", "y", true), + Entry("'Y' returns true", "Y", true), + Entry("'yes' returns true", "yes", true), + Entry("'YES' returns true", "YES", true), + Entry("'n' returns false", "n", false), + Entry("'N' returns false", "N", false), + Entry("'no' returns false", "no", false), + Entry("'NO' returns false", "NO", false), + Entry("invalid input returns false", "maybe", false), + ) + + It("returns default when input is empty", func() { + input := "\n" + p := &Prompter{ + reader: bufio.NewReader(strings.NewReader(input)), + interactive: true, + } + result := p.Bool("Enable feature", true) + Expect(result).To(BeTrue()) + }) + }) + }) + + Describe("Choice", func() { + Context("non-interactive mode", func() { + It("returns default value without prompting", func() { + p := NewPrompter(false) + choices := []string{"option1", "option2", "option3"} + result := p.Choice("Select option", choices, "option2") + Expect(result).To(Equal("option2")) + }) + }) + + Context("interactive mode", func() { + It("returns matching choice case-insensitively", func() { + input := "OPTION2\n" + choices := []string{"option1", "option2", "option3"} + p := &Prompter{ + reader: bufio.NewReader(strings.NewReader(input)), + interactive: true, + } + result := p.Choice("Select option", choices, "option1") + Expect(result).To(Equal("option2")) + }) + + It("returns default when input is empty", func() { + input := "\n" + choices := []string{"option1", "option2", "option3"} + p := &Prompter{ + reader: bufio.NewReader(strings.NewReader(input)), + interactive: true, + } + result := p.Choice("Select option", choices, "option2") + Expect(result).To(Equal("option2")) + }) + + It("returns default when input is invalid", func() { + input := "invalid-option\n" + choices := []string{"option1", "option2", "option3"} + p := &Prompter{ + reader: bufio.NewReader(strings.NewReader(input)), + interactive: true, + } + result := p.Choice("Select option", choices, "option1") + Expect(result).To(Equal("option1")) + }) + + It("handles exact match", func() { + input := "option2\n" + choices := []string{"option1", "option2", "option3"} + p := &Prompter{ + reader: bufio.NewReader(strings.NewReader(input)), + interactive: true, + } + result := p.Choice("Select option", choices, "option1") + Expect(result).To(Equal("option2")) + }) + }) + }) +}) From 3919226aa51bca3b5eee0ddaeb0c65728aee74e2 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Thu, 6 Nov 2025 15:28:08 +0100 Subject: [PATCH 05/24] ref: simplify install config --- cli/cmd/init_install_config.go | 103 +- internal/installer/collector.go | 203 +++ internal/installer/config_generator.go | 1165 ----------------- internal/installer/config_manager.go | 365 ++++++ internal/installer/secrets.go | 264 ++++ ...nfig_generator_test.go => secrets_test.go} | 47 +- internal/installer/types.go | 476 +++++++ internal/installer/utils_test.go | 50 + internal/util/filewriter.go | 17 + internal/util/mocks.go | 47 + 10 files changed, 1470 insertions(+), 1267 deletions(-) create mode 100644 internal/installer/collector.go delete mode 100644 internal/installer/config_generator.go create mode 100644 internal/installer/config_manager.go create mode 100644 internal/installer/secrets.go rename internal/installer/{config_generator_test.go => secrets_test.go} (80%) create mode 100644 internal/installer/types.go create mode 100644 internal/installer/utils_test.go diff --git a/cli/cmd/init_install_config.go b/cli/cmd/init_install_config.go index e8efdedc..40cce134 100644 --- a/cli/cmd/init_install_config.go +++ b/cli/cmd/init_install_config.go @@ -18,7 +18,7 @@ type InitInstallConfigCmd struct { cmd *cobra.Command Opts *InitInstallConfigOpts FileWriter util.FileIO - Generator *installer.ConfigGenerator + Generator installer.InstallConfigManager } type InitInstallConfigOpts struct { @@ -83,7 +83,29 @@ type InitInstallConfigOpts struct { CodesphereWorkspacePlanMaxReplica int } -// only other function would be CreateConfig(cg *ConfigGenerator) error +// TODO: Implement this function that should be the only function in RunE +// func (c *InitInstallConfigCmd) CreateConfig(icm installer.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 { @@ -100,8 +122,24 @@ func (c *InitInstallConfigCmd) RunE(_ *cobra.Command, args []string) error { fmt.Println("This wizard will help you create config.yaml and prod.vault.yaml for Codesphere installation.") fmt.Println() - // to function toconfigopts - configOpts := &installer.ConfigOptions{ + 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() *installer.ConfigOptions { + return &installer.ConfigOptions{ DatacenterID: c.Opts.DatacenterID, DatacenterName: c.Opts.DatacenterName, DatacenterCity: c.Opts.DatacenterCity, @@ -152,56 +190,9 @@ func (c *InitInstallConfigCmd) RunE(_ *cobra.Command, args []string) error { SecretsBaseDir: c.Opts.SecretsBaseDir, } +} - config, err := c.Generator.CollectConfiguration(configOpts) - if err != nil { - return fmt.Errorf("failed to collect configuration: %w", err) - } - - configYAML, err := installer.MarshalConfig(config) - if err != nil { - return fmt.Errorf("failed to marshal config.yaml: %w", err) - } - - if c.Opts.WithComments { - configYAML = installer.AddConfigComments(configYAML) - } - - // todo function fo config and vault - configFile, err := c.FileWriter.Create(c.Opts.ConfigFile) - if err != nil { - return fmt.Errorf("failed to create config file: %w", err) - } - defer util.CloseFileIgnoreError(configFile) - - if _, err = configFile.Write(configYAML); err != nil { - return fmt.Errorf("failed to write config file: %w", err) - } - - fmt.Printf("\nConfiguration file created: %s\n", c.Opts.ConfigFile) - - vault := config.ExtractVault() - vaultYAML, err := installer.MarshalVault(vault) - if err != nil { - return fmt.Errorf("failed to marshal vault.yaml: %w", err) - } - - if c.Opts.WithComments { - vaultYAML = installer.AddVaultComments(vaultYAML) - } - - vaultFile, err := c.FileWriter.Create(c.Opts.VaultFile) - if err != nil { - return fmt.Errorf("failed to create vault file: %w", err) - } - defer util.CloseFileIgnoreError(vaultFile) - - if _, err = vaultFile.Write(vaultYAML); err != nil { - return fmt.Errorf("failed to write vault file: %w", err) - } - - fmt.Printf("Secrets file created: %s\n", c.Opts.VaultFile) - +func (c *InitInstallConfigCmd) printSuccessMessage() { fmt.Println("\n" + strings.Repeat("=", 70)) fmt.Println("Configuration files successfully generated!") fmt.Println(strings.Repeat("=", 70)) @@ -218,8 +209,6 @@ func (c *InitInstallConfigCmd) RunE(_ *cobra.Command, args []string) error { 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() - - return nil } func (c *InitInstallConfigCmd) applyProfile() error { @@ -418,8 +407,6 @@ func AddInitInstallConfigCmd(init *cobra.Command, opts *GlobalOptions) { FileWriter: util.NewFilesystemWriter(), } - c.Generator = installer.NewConfigGenerator(c.Opts.Interactive) - 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") @@ -445,7 +432,7 @@ func AddInitInstallConfigCmd(init *cobra.Command, opts *GlobalOptions) { util.MarkFlagRequired(c.cmd, "vault") c.cmd.PreRun = func(cmd *cobra.Command, args []string) { - c.Generator.Interactive = c.Opts.Interactive + c.Generator = installer.NewConfigGenerator(c.Opts.Interactive) } c.cmd.RunE = c.RunE diff --git a/internal/installer/collector.go b/internal/installer/collector.go new file mode 100644 index 00000000..f5f7b411 --- /dev/null +++ b/internal/installer/collector.go @@ -0,0 +1,203 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import "fmt" + +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() (*collectedConfig, error) { + prompter := NewPrompter(g.Interactive) + opts := g.configOpts + collected := &collectedConfig{} + + 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 *ConfigOptions, collected *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 *ConfigOptions, collected *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 *ConfigOptions, collected *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 *ConfigOptions, collected *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([]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([]CephHost, len(opts.CephHosts)) + for i, host := range opts.CephHosts { + collected.cephHosts[i] = CephHost(host) + } + } +} + +func (g *InstallConfig) collectK8sConfig(prompter *Prompter, opts *ConfigOptions, collected *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 *ConfigOptions, collected *collectedConfig) { + 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 *ConfigOptions, collected *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([]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] = MetalLBPoolDef{ + Name: poolName, + IPAddresses: poolIPs, + } + } + } + } else if opts.MetalLBEnabled { + collected.metalLBEnabled = true + collected.metalLBPools = make([]MetalLBPoolDef, len(opts.MetalLBPools)) + for i, pool := range opts.MetalLBPools { + collected.metalLBPools[i] = MetalLBPoolDef(pool) + } + } +} + +func (g *InstallConfig) collectCodesphereConfig(prompter *Prompter, opts *ConfigOptions, collected *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_generator.go b/internal/installer/config_generator.go deleted file mode 100644 index f6b75707..00000000 --- a/internal/installer/config_generator.go +++ /dev/null @@ -1,1165 +0,0 @@ -// Copyright (c) Codesphere Inc. -// SPDX-License-Identifier: Apache-2.0 - -package installer - -import ( - "fmt" - "net" - "strings" - - "gopkg.in/yaml.v3" -) - -type ConfigGenerator struct { - Interactive bool - configOpts *ConfigOptions - config *InstallConfig -} - -func NewConfigGenerator(interactive bool) *ConfigGenerator { - return &ConfigGenerator{ - Interactive: interactive, - } -} - -type InstallConfig struct { - 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"` - 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 { - 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 ImageRef struct { - BomRef string `yaml:"bomRef"` -} - -type DeployConfig struct { - Images map[string]DeployImage `yaml:"images"` -} - -type DeployImage struct { - Name string `yaml:"name"` - SupportedUntil string `yaml:"supportedUntil"` - Flavors map[string]DeployFlavor `yaml:"flavors"` -} - -type DeployFlavor struct { - Image ImageRef `yaml:"image"` - Pool map[int]int `yaml:"pool"` -} - -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 -} - -// CollectConfiguration gathers all configuration needed for Codesphere installation. -// It uses the provided ConfigOptions to pre-fill values, and falls back to -// interactive prompts (if enabled) or defaults when options are missing. -// Returns a complete InstallConfig with generated secrets stored in yaml:"-" fields. -func (g *ConfigGenerator) CollectConfiguration(opts *ConfigOptions) (*InstallConfig, error) { - // 1. collectConfig, 2. convertConfi, 3. generateSecrets, 4. writeYamlFile, 5 writeVaultFile - - prompter := NewPrompter(g.Interactive) - - fmt.Println("=== Datacenter Configuration ===") - dcID := opts.DatacenterID - if dcID == 0 { - dcID = prompter.Int("Datacenter ID", 1) - } - dcName := opts.DatacenterName - if dcName == "" { - dcName = prompter.String("Datacenter name", "main") - } - dcCity := opts.DatacenterCity - if dcCity == "" { - dcCity = prompter.String("Datacenter city", "Karlsruhe") - } - dcCountry := opts.DatacenterCountryCode - if dcCountry == "" { - dcCountry = prompter.String("Country code", "DE") - } - - secretsBaseDir := opts.SecretsBaseDir - if secretsBaseDir == "" { - secretsBaseDir = prompter.String("Secrets base directory", "/root/secrets") - } - - fmt.Println("\n=== Container Registry Configuration ===") - registryServer := opts.RegistryServer - if registryServer == "" { - registryServer = prompter.String("Container registry server (e.g., ghcr.io, leave empty to skip)", "") - } - var registryConfig *RegistryConfig - if registryServer != "" { - registryConfig = &RegistryConfig{ - Server: registryServer, - ReplaceImagesInBom: opts.RegistryReplaceImages, - LoadContainerImages: opts.RegistryLoadContainerImgs, - } - if g.Interactive { - registryConfig.ReplaceImagesInBom = prompter.Bool("Replace images in BOM", true) - registryConfig.LoadContainerImages = prompter.Bool("Load container images from installer", false) - } - } - - fmt.Println("\n=== PostgreSQL Configuration ===") - pgMode := opts.PostgresMode - if pgMode == "" { - pgMode = prompter.Choice("PostgreSQL setup", []string{"install", "external"}, "install") - } - - var postgresConfig PostgresConfig - if pgMode == "install" { - pgPrimaryIP := opts.PostgresPrimaryIP - if pgPrimaryIP == "" { - pgPrimaryIP = prompter.String("Primary PostgreSQL server IP", "10.50.0.2") - } - pgPrimaryHost := opts.PostgresPrimaryHost - if pgPrimaryHost == "" { - pgPrimaryHost = prompter.String("Primary PostgreSQL hostname", "pg-primary-node") - } - - fmt.Println("Generating PostgreSQL certificates and passwords...") - pgCAKey, pgCACert, err := GenerateCA("PostgreSQL CA", "DE", "Karlsruhe", "Codesphere") - if err != nil { - return nil, fmt.Errorf("failed to generate PostgreSQL CA: %w", err) - } - pgPrimaryKey, pgPrimaryCert, err := GenerateServerCertificate(pgCAKey, pgCACert, pgPrimaryHost, []string{pgPrimaryIP}) - if err != nil { - return nil, fmt.Errorf("failed to generate primary PostgreSQL certificate: %w", err) - } - adminPassword := GeneratePassword(32) - replicaPassword := GeneratePassword(32) - - postgresConfig = PostgresConfig{ - CACertPem: pgCACert, - caCertPrivateKey: pgCAKey, - adminPassword: adminPassword, - replicaPassword: replicaPassword, - Primary: &PostgresPrimaryConfig{ - SSLConfig: SSLConfig{ - ServerCertPem: pgPrimaryCert, - }, - IP: pgPrimaryIP, - Hostname: pgPrimaryHost, - privateKey: pgPrimaryKey, - }, - } - - pgReplicaIP := opts.PostgresReplicaIP - pgReplicaName := opts.PostgresReplicaName - if g.Interactive { - hasReplica := prompter.Bool("Configure PostgreSQL replica", true) - if hasReplica { - if pgReplicaIP == "" { - pgReplicaIP = prompter.String("Replica PostgreSQL server IP", "10.50.0.3") - } - if pgReplicaName == "" { - pgReplicaName = prompter.String("Replica name (lowercase alphanumeric + underscore only)", "replica1") - } - } - } - - if pgReplicaIP != "" { - pgReplicaKey, pgReplicaCert, err := GenerateServerCertificate(pgCAKey, pgCACert, pgReplicaName, []string{pgReplicaIP}) - if err != nil { - return nil, fmt.Errorf("failed to generate replica PostgreSQL certificate: %w", err) - } - postgresConfig.Replica = &PostgresReplicaConfig{ - IP: pgReplicaIP, - Name: pgReplicaName, - SSLConfig: SSLConfig{ - ServerCertPem: pgReplicaCert, - }, - privateKey: pgReplicaKey, - } - } - - services := []string{"auth", "deployment", "ide", "marketplace", "payment", "public_api", "team", "workspace"} - postgresConfig.userPasswords = make(map[string]string) - for _, service := range services { - postgresConfig.userPasswords[service] = GeneratePassword(32) - } - } else { - pgExternal := opts.PostgresExternal - if pgExternal == "" { - pgExternal = prompter.String("External PostgreSQL server address", "postgres.example.com:5432") - } - postgresConfig = PostgresConfig{ - ServerAddress: pgExternal, - } - } - - fmt.Println("\n=== Ceph Configuration ===") - cephSubnet := opts.CephSubnet - if cephSubnet == "" { - cephSubnet = prompter.String("Ceph nodes subnet (CIDR)", "10.53.101.0/24") - } - - var cephHosts []CephHost - if len(opts.CephHosts) == 0 { - numHosts := prompter.Int("Number of Ceph hosts", 3) - cephHosts = make([]CephHost, numHosts) - for i := 0; i < numHosts; i++ { - fmt.Printf("\nCeph Host %d:\n", i+1) - cephHosts[i].Hostname = prompter.String(" Hostname (as shown by 'hostname' command)", fmt.Sprintf("ceph-node-%d", i)) - cephHosts[i].IPAddress = prompter.String(" IP address", fmt.Sprintf("10.53.101.%d", i+2)) - cephHosts[i].IsMaster = (i == 0) - } - } else { - cephHosts = make([]CephHost, len(opts.CephHosts)) - for i, host := range opts.CephHosts { - cephHosts[i] = CephHost(host) - } - } - - fmt.Println("Generating Ceph SSH keys...") - cephSSHPub, cephSSHPriv, err := GenerateSSHKeyPair() - if err != nil { - return nil, fmt.Errorf("failed to generate Ceph SSH keys: %w", err) - } - - cephConfig := CephConfig{ - CephAdmSSHKey: CephSSHKey{ - PublicKey: cephSSHPub, - }, - NodesSubnet: cephSubnet, - Hosts: cephHosts, - sshPrivateKey: cephSSHPriv, - OSDs: []CephOSD{ - { - SpecID: "default", - Placement: CephPlacement{ - HostPattern: "*", - }, - DataDevices: CephDataDevices{ - Size: "240G:300G", - Limit: 1, - }, - DBDevices: CephDBDevices{ - Size: "120G:150G", - Limit: 1, - }, - }, - }, - } - - fmt.Println("\n=== Kubernetes Configuration ===") - k8sManaged := opts.K8sManaged - if g.Interactive { - k8sManaged = prompter.Bool("Use Codesphere-managed Kubernetes (k0s)", true) - } - - var k8sConfig KubernetesConfig - k8sConfig.ManagedByCodesphere = k8sManaged - - if k8sManaged { - k8sAPIServer := opts.K8sAPIServer - if k8sAPIServer == "" { - k8sAPIServer = prompter.String("Kubernetes API server host (LB/DNS/IP)", "10.50.0.2") - } - var k8sControlPlane []string - if len(opts.K8sControlPlane) == 0 { - k8sControlPlane = prompter.StringSlice("Control plane IP addresses (comma-separated)", []string{"10.50.0.2"}) - } else { - k8sControlPlane = opts.K8sControlPlane - } - var k8sWorkers []string - if len(opts.K8sWorkers) == 0 { - k8sWorkers = prompter.StringSlice("Worker node IP addresses (comma-separated)", []string{"10.50.0.2", "10.50.0.3", "10.50.0.4"}) - } else { - k8sWorkers = opts.K8sWorkers - } - - k8sConfig.APIServerHost = k8sAPIServer - k8sConfig.ControlPlanes = make([]K8sNode, len(k8sControlPlane)) - for i, ip := range k8sControlPlane { - k8sConfig.ControlPlanes[i] = K8sNode{IPAddress: ip} - } - k8sConfig.Workers = make([]K8sNode, len(k8sWorkers)) - for i, ip := range k8sWorkers { - k8sConfig.Workers[i] = K8sNode{IPAddress: ip} - } - k8sConfig.needsKubeConfig = false - } else { - k8sPodCIDR := opts.K8sPodCIDR - if k8sPodCIDR == "" { - k8sPodCIDR = prompter.String("Pod CIDR of external cluster", "100.96.0.0/11") - } - k8sServiceCIDR := opts.K8sServiceCIDR - if k8sServiceCIDR == "" { - k8sServiceCIDR = prompter.String("Service CIDR of external cluster", "100.64.0.0/13") - } - k8sConfig.PodCIDR = k8sPodCIDR - k8sConfig.ServiceCIDR = k8sServiceCIDR - k8sConfig.needsKubeConfig = true - fmt.Println("Note: You'll need to provide kubeconfig in the vault file for external Kubernetes") - } - - fmt.Println("\n=== Cluster Gateway Configuration ===") - gatewayType := opts.ClusterGatewayType - if gatewayType == "" { - gatewayType = prompter.Choice("Gateway service type", []string{"LoadBalancer", "ExternalIP"}, "LoadBalancer") - } - var gatewayIPs []string - if gatewayType == "ExternalIP" && len(opts.ClusterGatewayIPs) == 0 { - gatewayIPs = prompter.StringSlice("Gateway IP addresses (comma-separated)", []string{"10.51.0.2", "10.51.0.3"}) - } else { - gatewayIPs = opts.ClusterGatewayIPs - } - - publicGatewayType := opts.ClusterPublicGatewayType - if publicGatewayType == "" { - publicGatewayType = prompter.Choice("Public gateway service type", []string{"LoadBalancer", "ExternalIP"}, "LoadBalancer") - } - var publicGatewayIPs []string - if publicGatewayType == "ExternalIP" && len(opts.ClusterPublicGatewayIPs) == 0 { - publicGatewayIPs = prompter.StringSlice("Public gateway IP addresses (comma-separated)", []string{"10.52.0.2", "10.52.0.3"}) - } else { - publicGatewayIPs = opts.ClusterPublicGatewayIPs - } - - fmt.Println("Generating ingress CA certificate...") - ingressCAKey, ingressCACert, err := GenerateCA("Cluster Ingress CA", "DE", "Karlsruhe", "Codesphere") - if err != nil { - return nil, fmt.Errorf("failed to generate ingress CA: %w", err) - } - - clusterConfig := ClusterConfig{ - Certificates: ClusterCertificates{ - CA: CAConfig{ - Algorithm: "RSA", - KeySizeBits: 2048, - CertPem: ingressCACert, - }, - }, - Gateway: GatewayConfig{ - ServiceType: gatewayType, - IPAddresses: gatewayIPs, - }, - PublicGateway: GatewayConfig{ - ServiceType: publicGatewayType, - IPAddresses: publicGatewayIPs, - }, - ingressCAKey: ingressCAKey, - } - - fmt.Println("\n=== MetalLB Configuration (Optional) ===") - var metalLBConfig *MetalLBConfig - if g.Interactive { - metalLBEnabled := prompter.Bool("Enable MetalLB", false) - if metalLBEnabled { - // TODO: configopttoxyaml - numPools := prompter.Int("Number of MetalLB IP pools", 1) - pools := make([]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"}) - pools[i] = MetalLBPoolDef{ - Name: poolName, - IPAddresses: poolIPs, - } - } - metalLBConfig = &MetalLBConfig{ - Enabled: true, - Pools: pools, - } - } - } else if opts.MetalLBEnabled { - pools := make([]MetalLBPoolDef, len(opts.MetalLBPools)) - for i, pool := range opts.MetalLBPools { - pools[i] = MetalLBPoolDef(pool) - } - metalLBConfig = &MetalLBConfig{ - Enabled: true, - Pools: pools, - } - } - - fmt.Println("\n=== Codesphere Application Configuration ===") - codesphereDomain := opts.CodesphereDomain - // todo in opts.CodesphereDomain - if codesphereDomain == "" { - codesphereDomain = prompter.String("Main Codesphere domain", "codesphere.yourcompany.com") - } - workspaceDomain := opts.CodesphereWorkspaceBaseDomain - if workspaceDomain == "" { - workspaceDomain = prompter.String("Workspace base domain (*.domain should point to public gateway)", "ws.yourcompany.com") - } - publicIP := opts.CodespherePublicIP - if publicIP == "" { - publicIP = prompter.String("Primary public IP for workspaces", "") - } - customDomain := opts.CodesphereCustomDomainBaseDomain - if customDomain == "" { - customDomain = prompter.String("Custom domain CNAME base", "custom.yourcompany.com") - } - var dnsServers []string - if len(opts.CodesphereDNSServers) == 0 { - dnsServers = prompter.StringSlice("DNS servers (comma-separated)", []string{"1.1.1.1", "8.8.8.8"}) - } else { - dnsServers = opts.CodesphereDNSServers - } - - fmt.Println("\n=== Workspace Plans Configuration ===") - workspaceImageBomRef := opts.CodesphereWorkspaceImageBomRef - if workspaceImageBomRef == "" { - workspaceImageBomRef = prompter.String("Workspace agent image BOM reference", "workspace-agent-24.04") - } - hostingPlanCPU := opts.CodesphereHostingPlanCPU - if hostingPlanCPU == 0 { - hostingPlanCPU = prompter.Int("Hosting plan CPU (tenths, e.g., 10 = 1 core)", 10) - } - hostingPlanMemory := opts.CodesphereHostingPlanMemory - if hostingPlanMemory == 0 { - hostingPlanMemory = prompter.Int("Hosting plan memory (MB)", 2048) - } - hostingPlanStorage := opts.CodesphereHostingPlanStorage - if hostingPlanStorage == 0 { - hostingPlanStorage = prompter.Int("Hosting plan storage (MB)", 20480) - } - hostingPlanTempStorage := opts.CodesphereHostingPlanTempStorage - if hostingPlanTempStorage == 0 { - hostingPlanTempStorage = prompter.Int("Hosting plan temp storage (MB)", 1024) - } - workspacePlanName := opts.CodesphereWorkspacePlanName - if workspacePlanName == "" { - workspacePlanName = prompter.String("Workspace plan name", "Standard Developer") - } - workspacePlanMaxReplica := opts.CodesphereWorkspacePlanMaxReplica - if workspacePlanMaxReplica == 0 { - workspacePlanMaxReplica = prompter.Int("Max replicas per workspace", 3) - } - - fmt.Println("Generating domain authentication keys...") - domainAuthPub, domainAuthPriv, err := GenerateECDSAKeyPair() - if err != nil { - return nil, fmt.Errorf("failed to generate domain auth keys: %w", err) - } - - codesphereConfig := CodesphereConfig{ - Domain: codesphereDomain, - WorkspaceHostingBaseDomain: workspaceDomain, - PublicIP: publicIP, - CustomDomains: CustomDomainsConfig{ - CNameBaseDomain: customDomain, - }, - DNSServers: dnsServers, - Experiments: []string{}, - domainAuthPrivateKey: domainAuthPriv, - domainAuthPublicKey: domainAuthPub, - DeployConfig: DeployConfig{ - Images: map[string]DeployImage{ - "ubuntu-24.04": { - Name: "Ubuntu 24.04", - SupportedUntil: "2028-05-31", - Flavors: map[string]DeployFlavor{ - "default": { - Image: ImageRef{ - BomRef: workspaceImageBomRef, - }, - Pool: map[int]int{1: 1}, - }, - }, - }, - }, - }, - Plans: PlansConfig{ - HostingPlans: map[int]HostingPlan{ - 1: { - CPUTenth: hostingPlanCPU, - GPUParts: 0, - MemoryMb: hostingPlanMemory, - StorageMb: hostingPlanStorage, - TempStorageMb: hostingPlanTempStorage, - }, - }, - WorkspacePlans: map[int]WorkspacePlan{ - 1: { - Name: workspacePlanName, - HostingPlanID: 1, - MaxReplicas: workspacePlanMaxReplica, - OnDemand: true, - }, - }, - }, - } - - config := &InstallConfig{ - DataCenter: DataCenterConfig{ - ID: dcID, - Name: dcName, - City: dcCity, - CountryCode: dcCountry, - }, - Secrets: SecretsConfig{ - BaseDir: secretsBaseDir, - }, - Registry: registryConfig, - Postgres: postgresConfig, - Ceph: cephConfig, - Kubernetes: k8sConfig, - Cluster: clusterConfig, - MetalLB: metalLBConfig, - Codesphere: codesphereConfig, - ManagedServiceBackends: &ManagedServiceBackendsConfig{ - Postgres: make(map[string]interface{}), - }, - } - - return config, nil -} - -// ExtractVault extracts all sensitive data from InstallConfig into a separate vault structure. -// This separates public configuration from secrets that should be encrypted and stored securely. -func (c *InstallConfig) ExtractVault() *InstallVault { - vault := &InstallVault{ - Secrets: []SecretEntry{}, - } - - 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, - }, - }, - ) - } - - if c.Cluster.ingressCAKey != "" { - vault.Secrets = append(vault.Secrets, SecretEntry{ - Name: "selfSignedCaKeyPem", - File: &SecretFile{ - Name: "key.pem", - Content: c.Cluster.ingressCAKey, - }, - }) - } - - if c.Ceph.sshPrivateKey != "" { - vault.Secrets = append(vault.Secrets, SecretEntry{ - Name: "cephSshPrivateKey", - File: &SecretFile{ - Name: "id_rsa", - Content: c.Ceph.sshPrivateKey, - }, - }) - } - - if c.Postgres.Primary != nil { - 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, - }, - }) - } - } - } - - vault.Secrets = append(vault.Secrets, SecretEntry{ - Name: "managedServiceSecrets", - Fields: &SecretFields{ - Password: "[]", - }, - }) - - if c.Registry != nil { - vault.Secrets = append(vault.Secrets, - SecretEntry{ - Name: "registryUsername", - Fields: &SecretFields{ - Password: "YOUR_REGISTRY_USERNAME", - }, - }, - SecretEntry{ - Name: "registryPassword", - Fields: &SecretFields{ - Password: "YOUR_REGISTRY_PASSWORD", - }, - }, - ) - } - - 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", - }, - }) - } - - return vault -} - -func Capitalize(s string) string { - if s == "" { - return "" - } - s = strings.ReplaceAll(s, "_", "") - return strings.ToUpper(s[:1]) + s[1:] -} - -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 *InstallConfig) []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 *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 *InstallConfig) ([]byte, error) { - return yaml.Marshal(config) -} - -func MarshalVault(vault *InstallVault) ([]byte, error) { - return yaml.Marshal(vault) -} - -func UnmarshalConfig(data []byte) (*InstallConfig, error) { - var config InstallConfig - err := yaml.Unmarshal(data, &config) - return &config, err -} - -func UnmarshalVault(data []byte) (*InstallVault, error) { - var vault InstallVault - err := yaml.Unmarshal(data, &vault) - return &vault, err -} diff --git a/internal/installer/config_manager.go b/internal/installer/config_manager.go new file mode 100644 index 00000000..f501ef6a --- /dev/null +++ b/internal/installer/config_manager.go @@ -0,0 +1,365 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "fmt" + "net" + + "github.com/codesphere-cloud/oms/internal/util" + "gopkg.in/yaml.v3" +) + +type InstallConfigManager interface { + CollectConfiguration(opts *ConfigOptions) (*InstallConfigContent, error) + WriteConfigAndVault(configPath, vaultPath string, withComments bool) error +} + +type InstallConfig struct { + Interactive bool + configOpts *ConfigOptions + config *InstallConfigContent + fileIO util.FileIO +} + +func NewConfigGenerator(interactive bool) InstallConfigManager { + return &InstallConfig{ + Interactive: interactive, + fileIO: &util.FilesystemWriter{}, + } +} + +func (g *InstallConfig) CollectConfiguration(opts *ConfigOptions) (*InstallConfigContent, 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 *collectedConfig) (*InstallConfigContent, error) { + config := &InstallConfigContent{ + DataCenter: DataCenterConfig{ + ID: collected.dcID, + Name: collected.dcName, + City: collected.dcCity, + CountryCode: collected.dcCountry, + }, + Secrets: SecretsConfig{ + BaseDir: collected.secretsBaseDir, + }, + } + + if collected.registryServer != "" { + config.Registry = &RegistryConfig{ + Server: collected.registryServer, + ReplaceImagesInBom: collected.registryReplaceImages, + LoadContainerImages: collected.registryLoadContainerImgs, + } + } + + if collected.pgMode == "install" { + config.Postgres = PostgresConfig{ + Primary: &PostgresPrimaryConfig{ + IP: collected.pgPrimaryIP, + Hostname: collected.pgPrimaryHost, + }, + } + + if collected.pgReplicaIP != "" { + config.Postgres.Replica = &PostgresReplicaConfig{ + IP: collected.pgReplicaIP, + Name: collected.pgReplicaName, + } + } + } else { + config.Postgres = PostgresConfig{ + ServerAddress: collected.pgExternal, + } + } + + config.Ceph = CephConfig{ + NodesSubnet: collected.cephSubnet, + Hosts: collected.cephHosts, + OSDs: []CephOSD{ + { + SpecID: "default", + Placement: CephPlacement{ + HostPattern: "*", + }, + DataDevices: CephDataDevices{ + Size: "240G:300G", + Limit: 1, + }, + DBDevices: CephDBDevices{ + Size: "120G:150G", + Limit: 1, + }, + }, + }, + } + + config.Kubernetes = KubernetesConfig{ + ManagedByCodesphere: collected.k8sManaged, + } + + if collected.k8sManaged { + config.Kubernetes.APIServerHost = collected.k8sAPIServer + config.Kubernetes.ControlPlanes = make([]K8sNode, len(collected.k8sControlPlane)) + for i, ip := range collected.k8sControlPlane { + config.Kubernetes.ControlPlanes[i] = K8sNode{IPAddress: ip} + } + config.Kubernetes.Workers = make([]K8sNode, len(collected.k8sWorkers)) + for i, ip := range collected.k8sWorkers { + config.Kubernetes.Workers[i] = K8sNode{IPAddress: ip} + } + config.Kubernetes.needsKubeConfig = false + } else { + config.Kubernetes.PodCIDR = collected.k8sPodCIDR + config.Kubernetes.ServiceCIDR = collected.k8sServiceCIDR + config.Kubernetes.needsKubeConfig = true + } + + config.Cluster = ClusterConfig{ + Certificates: ClusterCertificates{ + CA: CAConfig{ + Algorithm: "RSA", + KeySizeBits: 2048, + }, + }, + Gateway: GatewayConfig{ + ServiceType: collected.gatewayType, + IPAddresses: collected.gatewayIPs, + }, + PublicGateway: GatewayConfig{ + ServiceType: collected.publicGatewayType, + IPAddresses: collected.publicGatewayIPs, + }, + } + + if collected.metalLBEnabled { + config.MetalLB = &MetalLBConfig{ + Enabled: true, + Pools: collected.metalLBPools, + } + } + + config.Codesphere = CodesphereConfig{ + Domain: collected.codesphereDomain, + WorkspaceHostingBaseDomain: collected.workspaceDomain, + PublicIP: collected.publicIP, + CustomDomains: CustomDomainsConfig{ + CNameBaseDomain: collected.customDomain, + }, + DNSServers: collected.dnsServers, + Experiments: []string{}, + DeployConfig: DeployConfig{ + Images: map[string]DeployImage{ + "ubuntu-24.04": { + Name: "Ubuntu 24.04", + SupportedUntil: "2028-05-31", + Flavors: map[string]DeployFlavor{ + "default": { + Image: ImageRef{ + BomRef: collected.workspaceImageBomRef, + }, + Pool: map[int]int{1: 1}, + }, + }, + }, + }, + }, + Plans: PlansConfig{ + HostingPlans: map[int]HostingPlan{ + 1: { + CPUTenth: collected.hostingPlanCPU, + GPUParts: 0, + MemoryMb: collected.hostingPlanMemory, + StorageMb: collected.hostingPlanStorage, + TempStorageMb: collected.hostingPlanTempStorage, + }, + }, + WorkspacePlans: map[int]WorkspacePlan{ + 1: { + Name: collected.workspacePlanName, + HostingPlanID: 1, + MaxReplicas: collected.workspacePlanMaxReplica, + OnDemand: true, + }, + }, + }, + } + + config.ManagedServiceBackends = &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 *InstallConfigContent) []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 *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 *InstallConfigContent) ([]byte, error) { + return yaml.Marshal(config) +} + +func MarshalVault(vault *InstallVault) ([]byte, error) { + return yaml.Marshal(vault) +} + +func UnmarshalConfig(data []byte) (*InstallConfigContent, error) { + var config InstallConfigContent + err := yaml.Unmarshal(data, &config) + return &config, err +} + +func UnmarshalVault(data []byte) (*InstallVault, error) { + var vault InstallVault + err := yaml.Unmarshal(data, &vault) + return &vault, err +} diff --git a/internal/installer/secrets.go b/internal/installer/secrets.go new file mode 100644 index 00000000..3b2de826 --- /dev/null +++ b/internal/installer/secrets.go @@ -0,0 +1,264 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "fmt" + "strings" +) + +func (g *InstallConfig) generateSecrets(config *InstallConfigContent) 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 *InstallConfigContent) 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 +} + +func (c *InstallConfigContent) 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 *InstallConfigContent) 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 *InstallConfigContent) 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 *InstallConfigContent) 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 *InstallConfigContent) 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 *InstallConfigContent) addManagedServiceSecrets(vault *InstallVault) { + vault.Secrets = append(vault.Secrets, SecretEntry{ + Name: "managedServiceSecrets", + Fields: &SecretFields{ + Password: "[]", + }, + }) +} + +func (c *InstallConfigContent) addRegistrySecrets(vault *InstallVault) { + if c.Registry != nil { + 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 *InstallConfigContent) 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/config_generator_test.go b/internal/installer/secrets_test.go similarity index 80% rename from internal/installer/config_generator_test.go rename to internal/installer/secrets_test.go index ff66a5e7..e6aa90bc 100644 --- a/internal/installer/config_generator_test.go +++ b/internal/installer/secrets_test.go @@ -8,24 +8,9 @@ import ( . "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("ExtractVault", func() { It("extracts all secrets from config into vault format", func() { - config := &InstallConfig{ + config := &InstallConfigContent{ Postgres: PostgresConfig{ CACertPem: "-----BEGIN CERTIFICATE-----\nPG-CA\n-----END CERTIFICATE-----", caCertPrivateKey: "-----BEGIN RSA PRIVATE KEY-----\nPG-CA-KEY\n-----END RSA PRIVATE KEY-----", @@ -130,7 +115,7 @@ var _ = Describe("ExtractVault", func() { }) It("does not include kubeconfig for managed k8s", func() { - config := &InstallConfig{ + config := &InstallConfigContent{ Kubernetes: KubernetesConfig{ needsKubeConfig: false, }, @@ -158,7 +143,7 @@ var _ = Describe("ExtractVault", func() { userPasswords[service] = service + "-pass" } - config := &InstallConfig{ + config := &InstallConfigContent{ Postgres: PostgresConfig{ Primary: &PostgresPrimaryConfig{}, userPasswords: userPasswords, @@ -188,29 +173,3 @@ var _ = Describe("ExtractVault", func() { } }) }) - -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/installer/types.go b/internal/installer/types.go new file mode 100644 index 00000000..6683c764 --- /dev/null +++ b/internal/installer/types.go @@ -0,0 +1,476 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer + +type InstallConfigContent struct { + 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"` + 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 { + 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 ImageRef struct { + BomRef string `yaml:"bomRef"` +} + +type DeployConfig struct { + Images map[string]DeployImage `yaml:"images"` +} + +type DeployImage struct { + Name string `yaml:"name"` + SupportedUntil string `yaml:"supportedUntil"` + Flavors map[string]DeployFlavor `yaml:"flavors"` +} + +type DeployFlavor struct { + Image ImageRef `yaml:"image"` + Pool map[int]int `yaml:"pool"` +} + +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 +} diff --git a/internal/installer/utils_test.go b/internal/installer/utils_test.go new file mode 100644 index 00000000..d385f2c9 --- /dev/null +++ b/internal/installer/utils_test.go @@ -0,0 +1,50 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("IsValidIP", func() { + DescribeTable("IP validation", + func(ip string, valid bool) { + result := IsValidIP(ip) + Expect(result).To(Equal(valid)) + }, + Entry("valid IPv4", "192.168.1.1", true), + Entry("valid IPv6", "2001:db8::1", true), + Entry("invalid IP", "not-an-ip", false), + Entry("empty string", "", false), + Entry("partial IP", "192.168", false), + Entry("localhost", "127.0.0.1", true), + ) +}) + +var _ = Describe("AddConfigComments", func() { + It("adds header comments to config YAML", func() { + yamlData := []byte("test: value\n") + + result := AddConfigComments(yamlData) + resultStr := string(result) + + Expect(resultStr).To(ContainSubstring("Codesphere Installer Configuration")) + Expect(resultStr).To(ContainSubstring("test: value")) + }) +}) + +var _ = Describe("AddVaultComments", func() { + It("adds security warnings to vault YAML", func() { + yamlData := []byte("secrets:\n - name: test\n") + + result := AddVaultComments(yamlData) + resultStr := string(result) + + Expect(resultStr).To(ContainSubstring("Codesphere Installer Secrets")) + Expect(resultStr).To(ContainSubstring("IMPORTANT")) + Expect(resultStr).To(ContainSubstring("SOPS")) + Expect(resultStr).To(ContainSubstring("secrets:")) + }) +}) diff --git a/internal/util/filewriter.go b/internal/util/filewriter.go index b686039d..53660aa9 100644 --- a/internal/util/filewriter.go +++ b/internal/util/filewriter.go @@ -4,6 +4,7 @@ package util import ( + "fmt" "os" ) @@ -15,6 +16,7 @@ type FileIO interface { IsDirectory(filename string) (bool, error) Exists(filename string) bool ReadDir(dirname string) ([]os.DirEntry, error) + CreateAndWrite(filePath string, data []byte, fileType string) error } type FilesystemWriter struct{} @@ -27,6 +29,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 ae55b5ba..3b5ba6fc 100644 --- a/internal/util/mocks.go +++ b/internal/util/mocks.go @@ -93,6 +93,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) From d6a8c9e7ab8bd263be192c4215e63522142828a0 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Thu, 6 Nov 2025 15:37:47 +0100 Subject: [PATCH 06/24] fix: mocks --- internal/installer/mocks.go | 130 ++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/internal/installer/mocks.go b/internal/installer/mocks.go index 9779117e..46488cfb 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 *ConfigOptions) (*InstallConfigContent, error) { + ret := _mock.Called(opts) + + if len(ret) == 0 { + panic("no return value specified for CollectConfiguration") + } + + var r0 *InstallConfigContent + var r1 error + if returnFunc, ok := ret.Get(0).(func(*ConfigOptions) (*InstallConfigContent, error)); ok { + return returnFunc(opts) + } + if returnFunc, ok := ret.Get(0).(func(*ConfigOptions) *InstallConfigContent); ok { + r0 = returnFunc(opts) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*InstallConfigContent) + } + } + if returnFunc, ok := ret.Get(1).(func(*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 *ConfigOptions)) *MockInstallConfigManager_CollectConfiguration_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(*ConfigOptions)) + }) + return _c +} + +func (_c *MockInstallConfigManager_CollectConfiguration_Call) Return(installConfigContent *InstallConfigContent, err error) *MockInstallConfigManager_CollectConfiguration_Call { + _c.Call.Return(installConfigContent, err) + return _c +} + +func (_c *MockInstallConfigManager_CollectConfiguration_Call) RunAndReturn(run func(opts *ConfigOptions) (*InstallConfigContent, 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 { From bd14a56df86b3c3cca748a82512de6a600fa4485 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Thu, 6 Nov 2025 14:41:04 +0000 Subject: [PATCH 07/24] chore(docs): Auto-update docs and licenses Signed-off-by: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> --- docs/README.md | 3 +- docs/oms-cli.md | 3 +- docs/oms-cli_beta.md | 2 +- docs/oms-cli_beta_extend.md | 2 +- docs/oms-cli_beta_extend_baseimage.md | 2 +- docs/oms-cli_build.md | 2 +- docs/oms-cli_build_image.md | 2 +- docs/oms-cli_build_images.md | 2 +- docs/oms-cli_download.md | 2 +- docs/oms-cli_download_package.md | 2 +- docs/oms-cli_init.md | 20 --------- docs/oms-cli_init_install-config.md | 64 --------------------------- docs/oms-cli_install.md | 2 +- docs/oms-cli_install_codesphere.md | 2 +- docs/oms-cli_licenses.md | 2 +- docs/oms-cli_list.md | 2 +- docs/oms-cli_list_api-keys.md | 2 +- docs/oms-cli_list_packages.md | 2 +- docs/oms-cli_register.md | 2 +- docs/oms-cli_revoke.md | 2 +- docs/oms-cli_revoke_api-key.md | 2 +- docs/oms-cli_update.md | 2 +- docs/oms-cli_update_api-key.md | 2 +- docs/oms-cli_update_dockerfile.md | 2 +- docs/oms-cli_update_oms.md | 2 +- docs/oms-cli_update_package.md | 2 +- docs/oms-cli_version.md | 2 +- 27 files changed, 27 insertions(+), 109 deletions(-) delete mode 100644 docs/oms-cli_init.md delete mode 100644 docs/oms-cli_init_install-config.md diff --git a/docs/README.md b/docs/README.md index c1b30490..dabca51e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,6 +21,7 @@ like downloading new versions. * [oms-cli build](oms-cli_build.md) - Build and push images to a registry * [oms-cli download](oms-cli_download.md) - Download resources available through OMS * [oms-cli install](oms-cli_install.md) - Coming soon: Install Codesphere and other components +* [oms-cli 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 * [oms-cli register](oms-cli_register.md) - Register a new API key @@ -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 c1b30490..dabca51e 100644 --- a/docs/oms-cli.md +++ b/docs/oms-cli.md @@ -21,6 +21,7 @@ like downloading new versions. * [oms-cli build](oms-cli_build.md) - Build and push images to a registry * [oms-cli download](oms-cli_download.md) - Download resources available through OMS * [oms-cli install](oms-cli_install.md) - Coming soon: Install Codesphere and other components +* [oms-cli 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 * [oms-cli register](oms-cli_register.md) - Register a new API key @@ -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 1cbccfc1..23da2dcc 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 567ebef3..b9d85aa7 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 2b2569e0..b507f9e9 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 8f200629..fbb1445e 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 6d74e8b8..14c31da1 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 07ae413f..6dba74d6 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 c34ab2e9..b071f525 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 b9e323bf..13f6f263 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 deleted file mode 100644 index 128a4a41..00000000 --- a/docs/oms-cli_init.md +++ /dev/null @@ -1,20 +0,0 @@ -## 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 Gen0 installer configuration files - -###### Auto generated by spf13/cobra on 30-Oct-2025 diff --git a/docs/oms-cli_init_install-config.md b/docs/oms-cli_init_install-config.md deleted file mode 100644 index 02ee50d4..00000000 --- a/docs/oms-cli_init_install-config.md +++ /dev/null @@ -1,64 +0,0 @@ -## oms-cli init install-config - -Initialize Codesphere Gen0 installer configuration files - -### Synopsis - -Initialize config.yaml and prod.vault.yaml for the Codesphere Gen0 installer. - -This command generates two files: -- config.yaml: Main configuration (infrastructure, networking, plans) -- prod.vault.yaml: Secrets file (keys, certificates, passwords) - -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 Gen0 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 - --k8s-control-plane strings K8s control plane IPs (comma-separated) - --k8s-managed Use Codesphere-managed Kubernetes (default true) - --non-interactive Use default values without prompting - --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 30-Oct-2025 diff --git a/docs/oms-cli_install.md b/docs/oms-cli_install.md index c3530a5b..8d8c9fbf 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 716f2c53..4a8bbd11 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 23d01f97..79164df6 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 3bb421d5..ba7c1058 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 02274e0b..3cb744b3 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 9cb4e521..4823147f 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 7e7b93ce..effdc071 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 cac7846d..2373a188 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 a2746f85..3f074871 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 f8b93b90..ff493200 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 8d811f06..b5bc7d5b 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 57658229..9a23085c 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 9d36592f..1761a163 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 9d2d28b0..1bc4d559 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 bea79035..5d57e94e 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 From dc5369dd131ad68ea440fedb778a5bdcd11a583d Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Thu, 6 Nov 2025 15:41:17 +0100 Subject: [PATCH 08/24] fix: merge error --- cli/cmd/root.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 532194c3..b3a1108b 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -35,7 +35,7 @@ func GetRootCmd() *cobra.Command { AddListCmd(rootCmd, opts) AddDownloadCmd(rootCmd, opts) AddInstallCmd(rootCmd, opts) - AddInstallCmd(rootCmd, opts) + AddInitCmd(rootCmd, opts) AddBuildCmd(rootCmd, opts) AddLicensesCmd(rootCmd) From 396eed35ce1e2cea49682fe71bf02119a0bc88df Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Thu, 6 Nov 2025 14:42:23 +0000 Subject: [PATCH 09/24] chore(docs): Auto-update docs and licenses Signed-off-by: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> --- docs/README.md | 2 +- docs/oms-cli.md | 2 +- docs/oms-cli_init.md | 20 +++++++++ docs/oms-cli_init_install-config.md | 67 +++++++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 docs/oms-cli_init.md create mode 100644 docs/oms-cli_init_install-config.md diff --git a/docs/README.md b/docs/README.md index dabca51e..221a18f3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,7 +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 install](oms-cli_install.md) - Coming soon: Install Codesphere and other components +* [oms-cli init](oms-cli_init.md) - Initialize configuration files * [oms-cli install](oms-cli_install.md) - Coming soon: Install Codesphere and other components * [oms-cli licenses](oms-cli_licenses.md) - Print license information * [oms-cli list](oms-cli_list.md) - List resources available through OMS diff --git a/docs/oms-cli.md b/docs/oms-cli.md index dabca51e..221a18f3 100644 --- a/docs/oms-cli.md +++ b/docs/oms-cli.md @@ -20,7 +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 install](oms-cli_install.md) - Coming soon: Install Codesphere and other components +* [oms-cli init](oms-cli_init.md) - Initialize configuration files * [oms-cli install](oms-cli_install.md) - Coming soon: Install Codesphere and other components * [oms-cli licenses](oms-cli_licenses.md) - Print license information * [oms-cli list](oms-cli_list.md) - List resources available through OMS diff --git a/docs/oms-cli_init.md b/docs/oms-cli_init.md new file mode 100644 index 00000000..cba6c7b2 --- /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 00000000..f3830440 --- /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 From 0b0590cd1c18c1daf21fe2ee90c83c5d62b62e14 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Thu, 6 Nov 2025 17:32:24 +0100 Subject: [PATCH 10/24] ref: installer package to use files.RootConfig --- cli/cmd/init_install_config.go | 17 +- internal/installer/collector.go | 153 +++--- internal/installer/config_manager.go | 185 +++---- internal/installer/crypto_test.go | 6 - internal/installer/files/config_yaml.go | 622 +++++++++++++++++++++++- internal/installer/mocks.go | 22 +- internal/installer/secrets.go | 202 +------- internal/installer/secrets_test.go | 83 ++-- internal/installer/types.go | 476 ------------------ 9 files changed, 873 insertions(+), 893 deletions(-) delete mode 100644 internal/installer/types.go diff --git a/cli/cmd/init_install_config.go b/cli/cmd/init_install_config.go index 40cce134..7aad85e8 100644 --- a/cli/cmd/init_install_config.go +++ b/cli/cmd/init_install_config.go @@ -10,6 +10,7 @@ import ( 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" ) @@ -51,7 +52,7 @@ type InitInstallConfigOpts struct { PostgresExternal string CephSubnet string - CephHosts []installer.CephHostConfig + CephHosts []files.CephHostConfig K8sManaged bool K8sAPIServer string @@ -67,7 +68,7 @@ type InitInstallConfigOpts struct { ClusterPublicGatewayIPs []string MetalLBEnabled bool - MetalLBPools []installer.MetalLBPool + MetalLBPools []files.MetalLBPool CodesphereDomain string CodespherePublicIP string @@ -84,7 +85,7 @@ type InitInstallConfigOpts struct { } // TODO: Implement this function that should be the only function in RunE -// func (c *InitInstallConfigCmd) CreateConfig(icm installer.InstallConfigManager) error { +// func (c *InitInstallConfigCmd) CreateConfig(icm files.InstallConfigManager) error { // if c.Opts.Interactive { // _, err := icm.CollectConfiguration(c.cmd) // if err != nil { @@ -138,8 +139,8 @@ func (c *InitInstallConfigCmd) RunE(_ *cobra.Command, args []string) error { return nil } -func (c *InitInstallConfigCmd) buildConfigOptions() *installer.ConfigOptions { - return &installer.ConfigOptions{ +func (c *InitInstallConfigCmd) buildConfigOptions() *files.ConfigOptions { + return &files.ConfigOptions{ DatacenterID: c.Opts.DatacenterID, DatacenterName: c.Opts.DatacenterName, DatacenterCity: c.Opts.DatacenterCity, @@ -222,7 +223,7 @@ func (c *InitInstallConfigCmd) applyProfile() error { c.Opts.PostgresPrimaryIP = "127.0.0.1" c.Opts.PostgresPrimaryHost = "localhost" c.Opts.CephSubnet = "127.0.0.1/32" - c.Opts.CephHosts = []installer.CephHostConfig{{Hostname: "localhost", IPAddress: "127.0.0.1", IsMaster: true}} + 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"} @@ -256,7 +257,7 @@ func (c *InitInstallConfigCmd) applyProfile() error { c.Opts.PostgresReplicaIP = "10.50.0.3" c.Opts.PostgresReplicaName = "replica1" c.Opts.CephSubnet = "10.53.101.0/24" - c.Opts.CephHosts = []installer.CephHostConfig{ + 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}, @@ -291,7 +292,7 @@ func (c *InitInstallConfigCmd) applyProfile() error { c.Opts.PostgresPrimaryIP = "127.0.0.1" c.Opts.PostgresPrimaryHost = "localhost" c.Opts.CephSubnet = "127.0.0.1/32" - c.Opts.CephHosts = []installer.CephHostConfig{{Hostname: "localhost", IPAddress: "127.0.0.1", IsMaster: true}} + 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"} diff --git a/internal/installer/collector.go b/internal/installer/collector.go index f5f7b411..a02bee18 100644 --- a/internal/installer/collector.go +++ b/internal/installer/collector.go @@ -3,7 +3,11 @@ package installer -import "fmt" +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) { @@ -40,10 +44,12 @@ func (g *InstallConfig) collectChoice(prompter *Prompter, optValue, prompt strin }) } -func (g *InstallConfig) collectConfig() (*collectedConfig, error) { +func (g *InstallConfig) collectConfig() (*files.CollectedConfig, error) { prompter := NewPrompter(g.Interactive) opts := g.configOpts - collected := &collectedConfig{} + 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) @@ -57,147 +63,148 @@ func (g *InstallConfig) collectConfig() (*collectedConfig, error) { return collected, nil } -func (g *InstallConfig) collectDatacenterConfig(prompter *Prompter, opts *ConfigOptions, collected *collectedConfig) { +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") + 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 *ConfigOptions, collected *collectedConfig) { +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 + 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) + 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 *ConfigOptions, collected *collectedConfig) { +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") + 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 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") + 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 + collected.PgReplicaIP = opts.PostgresReplicaIP + collected.PgReplicaName = opts.PostgresReplicaName } } else { - collected.pgExternal = g.collectString(prompter, opts.PostgresExternal, "External PostgreSQL server address", "postgres.example.com:5432") + collected.PgExternal = g.collectString(prompter, opts.PostgresExternal, "External PostgreSQL server address", "postgres.example.com:5432") } } -func (g *InstallConfig) collectCephConfig(prompter *Prompter, opts *ConfigOptions, collected *collectedConfig) { +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") + 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([]CephHost, numHosts) + 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) + 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([]CephHost, len(opts.CephHosts)) + collected.CephHosts = make([]files.CephHost, len(opts.CephHosts)) for i, host := range opts.CephHosts { - collected.cephHosts[i] = CephHost(host) + collected.CephHosts[i] = files.CephHost(host) } } } -func (g *InstallConfig) collectK8sConfig(prompter *Prompter, opts *ConfigOptions, collected *collectedConfig) { +func (g *InstallConfig) collectK8sConfig(prompter *Prompter, opts *files.ConfigOptions, collected *files.CollectedConfig) { fmt.Println("\n=== Kubernetes Configuration ===") - collected.k8sManaged = opts.K8sManaged + collected.K8sManaged = opts.K8sManaged if g.Interactive { - collected.k8sManaged = prompter.Bool("Use Codesphere-managed Kubernetes (k0s)", true) + 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"}) + 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") + 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 *ConfigOptions, collected *collectedConfig) { +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"}) + 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.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"}) + 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 + collected.PublicGatewayIPs = opts.ClusterPublicGatewayIPs } } -func (g *InstallConfig) collectMetalLBConfig(prompter *Prompter, opts *ConfigOptions, collected *collectedConfig) { +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 { + collected.MetalLBEnabled = prompter.Bool("Enable MetalLB", false) + if collected.MetalLBEnabled { numPools := prompter.Int("Number of MetalLB IP pools", 1) - collected.metalLBPools = make([]MetalLBPoolDef, numPools) + 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] = MetalLBPoolDef{ + collected.MetalLBPools[i] = files.MetalLBPoolDef{ Name: poolName, IPAddresses: poolIPs, } } } } else if opts.MetalLBEnabled { - collected.metalLBEnabled = true - collected.metalLBPools = make([]MetalLBPoolDef, len(opts.MetalLBPools)) + collected.MetalLBEnabled = true + collected.MetalLBPools = make([]files.MetalLBPoolDef, len(opts.MetalLBPools)) for i, pool := range opts.MetalLBPools { - collected.metalLBPools[i] = MetalLBPoolDef(pool) + collected.MetalLBPools[i] = files.MetalLBPoolDef(pool) } } } -func (g *InstallConfig) collectCodesphereConfig(prompter *Prompter, opts *ConfigOptions, collected *collectedConfig) { +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"}) + 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) + 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 index f501ef6a..8d46cdc0 100644 --- a/internal/installer/config_manager.go +++ b/internal/installer/config_manager.go @@ -7,19 +7,20 @@ 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 *ConfigOptions) (*InstallConfigContent, error) + CollectConfiguration(opts *files.ConfigOptions) (*files.RootConfig, error) WriteConfigAndVault(configPath, vaultPath string, withComments bool) error } type InstallConfig struct { Interactive bool - configOpts *ConfigOptions - config *InstallConfigContent + configOpts *files.ConfigOptions + config *files.RootConfig fileIO util.FileIO } @@ -30,7 +31,7 @@ func NewConfigGenerator(interactive bool) InstallConfigManager { } } -func (g *InstallConfig) CollectConfiguration(opts *ConfigOptions) (*InstallConfigContent, error) { +func (g *InstallConfig) CollectConfiguration(opts *files.ConfigOptions) (*files.RootConfig, error) { g.configOpts = opts collectedOpts, err := g.collectConfig() @@ -52,61 +53,61 @@ func (g *InstallConfig) CollectConfiguration(opts *ConfigOptions) (*InstallConfi return config, nil } -func (g *InstallConfig) convertConfig(collected *collectedConfig) (*InstallConfigContent, error) { - config := &InstallConfigContent{ - DataCenter: DataCenterConfig{ - ID: collected.dcID, - Name: collected.dcName, - City: collected.dcCity, - CountryCode: collected.dcCountry, +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: SecretsConfig{ - BaseDir: collected.secretsBaseDir, + Secrets: files.SecretsConfig{ + BaseDir: collected.SecretsBaseDir, }, } - if collected.registryServer != "" { - config.Registry = &RegistryConfig{ - Server: collected.registryServer, - ReplaceImagesInBom: collected.registryReplaceImages, - LoadContainerImages: collected.registryLoadContainerImgs, + if collected.RegistryServer != "" { + config.Registry = files.RegistryConfig{ + Server: collected.RegistryServer, + ReplaceImagesInBom: collected.RegistryReplaceImages, + LoadContainerImages: collected.RegistryLoadContainerImgs, } } - if collected.pgMode == "install" { - config.Postgres = PostgresConfig{ - Primary: &PostgresPrimaryConfig{ - IP: collected.pgPrimaryIP, - Hostname: collected.pgPrimaryHost, + if collected.PgMode == "install" { + config.Postgres = files.PostgresConfig{ + Primary: &files.PostgresPrimaryConfig{ + IP: collected.PgPrimaryIP, + Hostname: collected.PgPrimaryHost, }, } - if collected.pgReplicaIP != "" { - config.Postgres.Replica = &PostgresReplicaConfig{ - IP: collected.pgReplicaIP, - Name: collected.pgReplicaName, + if collected.PgReplicaIP != "" { + config.Postgres.Replica = &files.PostgresReplicaConfig{ + IP: collected.PgReplicaIP, + Name: collected.PgReplicaName, } } } else { - config.Postgres = PostgresConfig{ - ServerAddress: collected.pgExternal, + config.Postgres = files.PostgresConfig{ + ServerAddress: collected.PgExternal, } } - config.Ceph = CephConfig{ - NodesSubnet: collected.cephSubnet, - Hosts: collected.cephHosts, - OSDs: []CephOSD{ + config.Ceph = files.CephConfig{ + NodesSubnet: collected.CephSubnet, + Hosts: collected.CephHosts, + OSDs: []files.CephOSD{ { SpecID: "default", - Placement: CephPlacement{ + Placement: files.CephPlacement{ HostPattern: "*", }, - DataDevices: CephDataDevices{ + DataDevices: files.CephDataDevices{ Size: "240G:300G", Limit: 1, }, - DBDevices: CephDBDevices{ + DBDevices: files.CephDBDevices{ Size: "120G:150G", Limit: 1, }, @@ -114,69 +115,69 @@ func (g *InstallConfig) convertConfig(collected *collectedConfig) (*InstallConfi }, } - config.Kubernetes = KubernetesConfig{ - ManagedByCodesphere: collected.k8sManaged, + config.Kubernetes = files.KubernetesConfig{ + ManagedByCodesphere: collected.K8sManaged, } - if collected.k8sManaged { - config.Kubernetes.APIServerHost = collected.k8sAPIServer - config.Kubernetes.ControlPlanes = make([]K8sNode, len(collected.k8sControlPlane)) - for i, ip := range collected.k8sControlPlane { - config.Kubernetes.ControlPlanes[i] = K8sNode{IPAddress: ip} + 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([]K8sNode, len(collected.k8sWorkers)) - for i, ip := range collected.k8sWorkers { - config.Kubernetes.Workers[i] = 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 + config.Kubernetes.NeedsKubeConfig = false } else { - config.Kubernetes.PodCIDR = collected.k8sPodCIDR - config.Kubernetes.ServiceCIDR = collected.k8sServiceCIDR - config.Kubernetes.needsKubeConfig = true + config.Kubernetes.PodCIDR = collected.K8sPodCIDR + config.Kubernetes.ServiceCIDR = collected.K8sServiceCIDR + config.Kubernetes.NeedsKubeConfig = true } - config.Cluster = ClusterConfig{ - Certificates: ClusterCertificates{ - CA: CAConfig{ + config.Cluster = files.ClusterConfig{ + Certificates: files.ClusterCertificates{ + CA: files.CAConfig{ Algorithm: "RSA", KeySizeBits: 2048, }, }, - Gateway: GatewayConfig{ - ServiceType: collected.gatewayType, - IPAddresses: collected.gatewayIPs, + Gateway: files.GatewayConfig{ + ServiceType: collected.GatewayType, + IPAddresses: collected.GatewayIPs, }, - PublicGateway: GatewayConfig{ - ServiceType: collected.publicGatewayType, - IPAddresses: collected.publicGatewayIPs, + PublicGateway: files.GatewayConfig{ + ServiceType: collected.PublicGatewayType, + IPAddresses: collected.PublicGatewayIPs, }, } - if collected.metalLBEnabled { - config.MetalLB = &MetalLBConfig{ + if collected.MetalLBEnabled { + config.MetalLB = &files.MetalLBConfig{ Enabled: true, - Pools: collected.metalLBPools, + Pools: collected.MetalLBPools, } } - config.Codesphere = CodesphereConfig{ - Domain: collected.codesphereDomain, - WorkspaceHostingBaseDomain: collected.workspaceDomain, - PublicIP: collected.publicIP, - CustomDomains: CustomDomainsConfig{ - CNameBaseDomain: collected.customDomain, + config.Codesphere = files.CodesphereConfig{ + Domain: collected.CodesphereDomain, + WorkspaceHostingBaseDomain: collected.WorkspaceDomain, + PublicIP: collected.PublicIP, + CustomDomains: files.CustomDomainsConfig{ + CNameBaseDomain: collected.CustomDomain, }, - DNSServers: collected.dnsServers, + DNSServers: collected.DnsServers, Experiments: []string{}, - DeployConfig: DeployConfig{ - Images: map[string]DeployImage{ + DeployConfig: files.DeployConfig{ + Images: map[string]files.ImageConfig{ "ubuntu-24.04": { Name: "Ubuntu 24.04", SupportedUntil: "2028-05-31", - Flavors: map[string]DeployFlavor{ + Flavors: map[string]files.FlavorConfig{ "default": { - Image: ImageRef{ - BomRef: collected.workspaceImageBomRef, + Image: files.ImageRef{ + BomRef: collected.WorkspaceImageBomRef, }, Pool: map[int]int{1: 1}, }, @@ -184,28 +185,28 @@ func (g *InstallConfig) convertConfig(collected *collectedConfig) (*InstallConfi }, }, }, - Plans: PlansConfig{ - HostingPlans: map[int]HostingPlan{ + Plans: files.PlansConfig{ + HostingPlans: map[int]files.HostingPlan{ 1: { - CPUTenth: collected.hostingPlanCPU, + CPUTenth: collected.HostingPlanCPU, GPUParts: 0, - MemoryMb: collected.hostingPlanMemory, - StorageMb: collected.hostingPlanStorage, - TempStorageMb: collected.hostingPlanTempStorage, + MemoryMb: collected.HostingPlanMemory, + StorageMb: collected.HostingPlanStorage, + TempStorageMb: collected.HostingPlanTempStorage, }, }, - WorkspacePlans: map[int]WorkspacePlan{ + WorkspacePlans: map[int]files.WorkspacePlan{ 1: { - Name: collected.workspacePlanName, + Name: collected.WorkspacePlanName, HostingPlanID: 1, - MaxReplicas: collected.workspacePlanMaxReplica, + MaxReplicas: collected.WorkspacePlanMaxReplica, OnDemand: true, }, }, }, } - config.ManagedServiceBackends = &ManagedServiceBackendsConfig{ + config.ManagedServiceBackends = &files.ManagedServiceBackendsConfig{ Postgres: make(map[string]interface{}), } @@ -283,7 +284,7 @@ func AddVaultComments(yamlData []byte) []byte { return append([]byte(header), yamlData...) } -func ValidateConfig(config *InstallConfigContent) []string { +func ValidateConfig(config *files.RootConfig) []string { errors := []string{} if config.DataCenter.ID == 0 { @@ -322,7 +323,7 @@ func ValidateConfig(config *InstallConfigContent) []string { return errors } -func ValidateVault(vault *InstallVault) []string { +func ValidateVault(vault *files.InstallVault) []string { errors := []string{} requiredSecrets := []string{"cephSshPrivateKey", "selfSignedCaKeyPem", "domainAuthPrivateKey", "domainAuthPublicKey"} foundSecrets := make(map[string]bool) @@ -344,22 +345,22 @@ func IsValidIP(ip string) bool { return net.ParseIP(ip) != nil } -func MarshalConfig(config *InstallConfigContent) ([]byte, error) { +func MarshalConfig(config *files.RootConfig) ([]byte, error) { return yaml.Marshal(config) } -func MarshalVault(vault *InstallVault) ([]byte, error) { +func MarshalVault(vault *files.InstallVault) ([]byte, error) { return yaml.Marshal(vault) } -func UnmarshalConfig(data []byte) (*InstallConfigContent, error) { - var config InstallConfigContent +func UnmarshalConfig(data []byte) (*files.RootConfig, error) { + var config files.RootConfig err := yaml.Unmarshal(data, &config) return &config, err } -func UnmarshalVault(data []byte) (*InstallVault, error) { - var vault InstallVault +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_test.go b/internal/installer/crypto_test.go index 31a68e8b..faeaa69e 100644 --- a/internal/installer/crypto_test.go +++ b/internal/installer/crypto_test.go @@ -6,18 +6,12 @@ package installer import ( "crypto/x509" "encoding/pem" - "testing" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "golang.org/x/crypto/ssh" ) -func TestInstaller(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "Installer Suite") -} - var _ = Describe("GenerateSSHKeyPair", func() { It("generates a valid SSH key pair", func() { privKey, pubKey, err := GenerateSSHKeyPair() diff --git a/internal/installer/files/config_yaml.go b/internal/installer/files/config_yaml.go index d31eab68..09f53c09 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 46488cfb..3e96b7ef 100644 --- a/internal/installer/mocks.go +++ b/internal/installer/mocks.go @@ -119,26 +119,26 @@ func (_m *MockInstallConfigManager) EXPECT() *MockInstallConfigManager_Expecter } // CollectConfiguration provides a mock function for the type MockInstallConfigManager -func (_mock *MockInstallConfigManager) CollectConfiguration(opts *ConfigOptions) (*InstallConfigContent, error) { +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 *InstallConfigContent + var r0 *files.RootConfig var r1 error - if returnFunc, ok := ret.Get(0).(func(*ConfigOptions) (*InstallConfigContent, error)); ok { + if returnFunc, ok := ret.Get(0).(func(*files.ConfigOptions) (*files.RootConfig, error)); ok { return returnFunc(opts) } - if returnFunc, ok := ret.Get(0).(func(*ConfigOptions) *InstallConfigContent); ok { + 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).(*InstallConfigContent) + r0 = ret.Get(0).(*files.RootConfig) } } - if returnFunc, ok := ret.Get(1).(func(*ConfigOptions) error); ok { + if returnFunc, ok := ret.Get(1).(func(*files.ConfigOptions) error); ok { r1 = returnFunc(opts) } else { r1 = ret.Error(1) @@ -157,19 +157,19 @@ func (_e *MockInstallConfigManager_Expecter) CollectConfiguration(opts interface return &MockInstallConfigManager_CollectConfiguration_Call{Call: _e.mock.On("CollectConfiguration", opts)} } -func (_c *MockInstallConfigManager_CollectConfiguration_Call) Run(run func(opts *ConfigOptions)) *MockInstallConfigManager_CollectConfiguration_Call { +func (_c *MockInstallConfigManager_CollectConfiguration_Call) Run(run func(opts *files.ConfigOptions)) *MockInstallConfigManager_CollectConfiguration_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(*ConfigOptions)) + run(args[0].(*files.ConfigOptions)) }) return _c } -func (_c *MockInstallConfigManager_CollectConfiguration_Call) Return(installConfigContent *InstallConfigContent, err error) *MockInstallConfigManager_CollectConfiguration_Call { - _c.Call.Return(installConfigContent, err) +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 *ConfigOptions) (*InstallConfigContent, error)) *MockInstallConfigManager_CollectConfiguration_Call { +func (_c *MockInstallConfigManager_CollectConfiguration_Call) RunAndReturn(run func(opts *files.ConfigOptions) (*files.RootConfig, error)) *MockInstallConfigManager_CollectConfiguration_Call { _c.Call.Return(run) return _c } diff --git a/internal/installer/secrets.go b/internal/installer/secrets.go index 3b2de826..a4f225db 100644 --- a/internal/installer/secrets.go +++ b/internal/installer/secrets.go @@ -5,17 +5,18 @@ package installer import ( "fmt" - "strings" + + "github.com/codesphere-cloud/oms/internal/installer/files" ) -func (g *InstallConfig) generateSecrets(config *InstallConfigContent) error { +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 + config.Codesphere.DomainAuthPublicKey = domainAuthPub + config.Codesphere.DomainAuthPrivateKey = domainAuthPriv fmt.Println("Generating ingress CA certificate...") ingressCAKey, ingressCACert, err := GenerateCA("Cluster Ingress CA", "DE", "Karlsruhe", "Codesphere") @@ -23,7 +24,7 @@ func (g *InstallConfig) generateSecrets(config *InstallConfigContent) error { return fmt.Errorf("failed to generate ingress CA: %w", err) } config.Cluster.Certificates.CA.CertPem = ingressCACert - config.Cluster.ingressCAKey = ingressCAKey + config.Cluster.IngressCAKey = ingressCAKey fmt.Println("Generating Ceph SSH keys...") cephSSHPub, cephSSHPriv, err := GenerateSSHKeyPair() @@ -31,7 +32,7 @@ func (g *InstallConfig) generateSecrets(config *InstallConfigContent) error { return fmt.Errorf("failed to generate Ceph SSH keys: %w", err) } config.Ceph.CephAdmSSHKey.PublicKey = cephSSHPub - config.Ceph.sshPrivateKey = cephSSHPriv + config.Ceph.SshPrivateKey = cephSSHPriv if config.Postgres.Primary != nil { if err := g.generatePostgresSecrets(config); err != nil { @@ -42,7 +43,7 @@ func (g *InstallConfig) generateSecrets(config *InstallConfigContent) error { return nil } -func (g *InstallConfig) generatePostgresSecrets(config *InstallConfigContent) error { +func (g *InstallConfig) generatePostgresSecrets(config *files.RootConfig) error { fmt.Println("Generating PostgreSQL certificates and passwords...") pgCAKey, pgCACert, err := GenerateCA("PostgreSQL CA", "DE", "Karlsruhe", "Codesphere") @@ -50,7 +51,7 @@ func (g *InstallConfig) generatePostgresSecrets(config *InstallConfigContent) er return fmt.Errorf("failed to generate PostgreSQL CA: %w", err) } config.Postgres.CACertPem = pgCACert - config.Postgres.caCertPrivateKey = pgCAKey + config.Postgres.CaCertPrivateKey = pgCAKey pgPrimaryKey, pgPrimaryCert, err := GenerateServerCertificate( pgCAKey, @@ -62,10 +63,10 @@ func (g *InstallConfig) generatePostgresSecrets(config *InstallConfigContent) er return fmt.Errorf("failed to generate primary PostgreSQL certificate: %w", err) } config.Postgres.Primary.SSLConfig.ServerCertPem = pgPrimaryCert - config.Postgres.Primary.privateKey = pgPrimaryKey + config.Postgres.Primary.PrivateKey = pgPrimaryKey - config.Postgres.adminPassword = GeneratePassword(32) - config.Postgres.replicaPassword = GeneratePassword(32) + config.Postgres.AdminPassword = GeneratePassword(32) + config.Postgres.ReplicaPassword = GeneratePassword(32) if config.Postgres.Replica != nil { pgReplicaKey, pgReplicaCert, err := GenerateServerCertificate( @@ -78,187 +79,14 @@ func (g *InstallConfig) generatePostgresSecrets(config *InstallConfigContent) er return fmt.Errorf("failed to generate replica PostgreSQL certificate: %w", err) } config.Postgres.Replica.SSLConfig.ServerCertPem = pgReplicaCert - config.Postgres.Replica.privateKey = pgReplicaKey + config.Postgres.Replica.PrivateKey = pgReplicaKey } services := []string{"auth", "deployment", "ide", "marketplace", "payment", "public_api", "team", "workspace"} - config.Postgres.userPasswords = make(map[string]string) + config.Postgres.UserPasswords = make(map[string]string) for _, service := range services { - config.Postgres.userPasswords[service] = GeneratePassword(32) + config.Postgres.UserPasswords[service] = GeneratePassword(32) } return nil } - -func (c *InstallConfigContent) 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 *InstallConfigContent) 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 *InstallConfigContent) 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 *InstallConfigContent) 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 *InstallConfigContent) 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 *InstallConfigContent) addManagedServiceSecrets(vault *InstallVault) { - vault.Secrets = append(vault.Secrets, SecretEntry{ - Name: "managedServiceSecrets", - Fields: &SecretFields{ - Password: "[]", - }, - }) -} - -func (c *InstallConfigContent) addRegistrySecrets(vault *InstallVault) { - if c.Registry != nil { - 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 *InstallConfigContent) 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/secrets_test.go b/internal/installer/secrets_test.go index e6aa90bc..14fbd4b1 100644 --- a/internal/installer/secrets_test.go +++ b/internal/installer/secrets_test.go @@ -4,51 +4,54 @@ 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 := &InstallConfigContent{ - Postgres: PostgresConfig{ + 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: &PostgresPrimaryConfig{ - SSLConfig: SSLConfig{ + 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-----", + PrivateKey: "-----BEGIN RSA PRIVATE KEY-----\nPG-PRIMARY-KEY\n-----END RSA PRIVATE KEY-----", }, - Replica: &PostgresReplicaConfig{ + Replica: &files.PostgresReplicaConfig{ IP: "10.50.0.3", Name: "replica1", - SSLConfig: SSLConfig{ + SSLConfig: files.SSLConfig{ ServerCertPem: "-----BEGIN CERTIFICATE-----\nPG-REPLICA\n-----END CERTIFICATE-----", }, - privateKey: "-----BEGIN RSA PRIVATE KEY-----\nPG-REPLICA-KEY\n-----END RSA PRIVATE KEY-----", + PrivateKey: "-----BEGIN RSA PRIVATE KEY-----\nPG-REPLICA-KEY\n-----END RSA PRIVATE KEY-----", }, - userPasswords: map[string]string{ + UserPasswords: map[string]string{ "auth": "auth-pass", "deployment": "deploy-pass", }, }, - Ceph: CephConfig{ - sshPrivateKey: "-----BEGIN RSA PRIVATE KEY-----\nCEPH-SSH\n-----END RSA PRIVATE KEY-----", + Ceph: files.CephConfig{ + SshPrivateKey: "-----BEGIN RSA PRIVATE KEY-----\nCEPH-SSH\n-----END RSA PRIVATE KEY-----", }, - Cluster: ClusterConfig{ - ingressCAKey: "-----BEGIN RSA PRIVATE KEY-----\nINGRESS-CA-KEY\n-----END RSA PRIVATE KEY-----", + Cluster: files.ClusterConfig{ + IngressCAKey: "-----BEGIN RSA PRIVATE KEY-----\nINGRESS-CA-KEY\n-----END RSA PRIVATE KEY-----", }, - Codesphere: 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-----", + 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: KubernetesConfig{ - needsKubeConfig: true, + Kubernetes: files.KubernetesConfig{ + NeedsKubeConfig: true, }, } @@ -115,13 +118,13 @@ var _ = Describe("ExtractVault", func() { }) It("does not include kubeconfig for managed k8s", func() { - config := &InstallConfigContent{ - Kubernetes: KubernetesConfig{ - needsKubeConfig: false, + config := &files.RootConfig{ + Kubernetes: files.KubernetesConfig{ + NeedsKubeConfig: false, }, - Codesphere: CodesphereConfig{ - domainAuthPrivateKey: "test-key", - domainAuthPublicKey: "test-pub", + Codesphere: files.CodesphereConfig{ + DomainAuthPrivateKey: "test-key", + DomainAuthPublicKey: "test-pub", }, } @@ -143,14 +146,14 @@ var _ = Describe("ExtractVault", func() { userPasswords[service] = service + "-pass" } - config := &InstallConfigContent{ - Postgres: PostgresConfig{ - Primary: &PostgresPrimaryConfig{}, - userPasswords: userPasswords, + config := &files.RootConfig{ + Postgres: files.PostgresConfig{ + Primary: &files.PostgresPrimaryConfig{}, + UserPasswords: userPasswords, }, - Codesphere: CodesphereConfig{ - domainAuthPrivateKey: "test", - domainAuthPublicKey: "test", + Codesphere: files.CodesphereConfig{ + DomainAuthPrivateKey: "test", + DomainAuthPublicKey: "test", }, } @@ -160,10 +163,10 @@ var _ = Describe("ExtractVault", func() { foundUser := false foundPass := false for _, secret := range vault.Secrets { - if secret.Name == "postgresUser"+Capitalize(service) { + if secret.Name == "postgresUser"+capitalize(service) { foundUser = true } - if secret.Name == "postgresPassword"+Capitalize(service) { + if secret.Name == "postgresPassword"+capitalize(service) { foundPass = true Expect(secret.Fields.Password).To(Equal(service + "-pass")) } @@ -173,3 +176,11 @@ var _ = Describe("ExtractVault", func() { } }) }) + +func capitalize(s string) string { + if s == "" { + return "" + } + s = strings.ReplaceAll(s, "_", "") + return strings.ToUpper(s[:1]) + s[1:] +} diff --git a/internal/installer/types.go b/internal/installer/types.go deleted file mode 100644 index 6683c764..00000000 --- a/internal/installer/types.go +++ /dev/null @@ -1,476 +0,0 @@ -// Copyright (c) Codesphere Inc. -// SPDX-License-Identifier: Apache-2.0 - -package installer - -type InstallConfigContent struct { - 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"` - 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 { - 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 ImageRef struct { - BomRef string `yaml:"bomRef"` -} - -type DeployConfig struct { - Images map[string]DeployImage `yaml:"images"` -} - -type DeployImage struct { - Name string `yaml:"name"` - SupportedUntil string `yaml:"supportedUntil"` - Flavors map[string]DeployFlavor `yaml:"flavors"` -} - -type DeployFlavor struct { - Image ImageRef `yaml:"image"` - Pool map[int]int `yaml:"pool"` -} - -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 -} From 0192f6284e49c4fefd554989de00f4dcecca7824 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Mon, 10 Nov 2025 09:54:02 +0100 Subject: [PATCH 11/24] ref: streamline configuration collection and management methods --- cli/cmd/init_install_config.go | 86 +++++---- internal/installer/collector.go | 98 ++++------ internal/installer/config_manager.go | 84 ++++++--- internal/installer/mocks.go | 263 ++++++++++++++++++++++++--- 4 files changed, 373 insertions(+), 158 deletions(-) diff --git a/cli/cmd/init_install_config.go b/cli/cmd/init_install_config.go index 7aad85e8..65f2e68d 100644 --- a/cli/cmd/init_install_config.go +++ b/cli/cmd/init_install_config.go @@ -84,30 +84,6 @@ type InitInstallConfigOpts struct { 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() @@ -119,26 +95,60 @@ func (c *InitInstallConfigCmd) RunE(_ *cobra.Command, args []string) error { } } - fmt.Println("Welcome to OMS!") - fmt.Println("This wizard will help you create config.yaml and prod.vault.yaml for Codesphere installation.") - fmt.Println() + c.printWelcomeMessage() - configOpts := c.buildConfigOptions() + if err := c.createConfig(); err != nil { + return err + } - _, err := c.Generator.CollectConfiguration(configOpts) + c.printSuccessMessage() + + return nil +} + +func (c *InitInstallConfigCmd) createConfig() error { + collected, err := c.collectConfiguration() if err != nil { - return fmt.Errorf("failed to collect configuration: %w", err) + return err } - if err := c.Generator.WriteConfigAndVault(c.Opts.ConfigFile, c.Opts.VaultFile, c.Opts.WithComments); err != nil { - return err + config, err := c.Generator.ConvertToConfig(collected) + if err != nil { + return fmt.Errorf("failed to convert configuration: %w", err) } - c.printSuccessMessage() + if err := c.Generator.GenerateSecrets(config); err != nil { + return fmt.Errorf("failed to generate secrets: %w", err) + } + + if err := c.Generator.WriteConfig(config, c.Opts.ConfigFile, c.Opts.WithComments); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + + if err := c.Generator.WriteVault(config, c.Opts.VaultFile, c.Opts.WithComments); err != nil { + return fmt.Errorf("failed to write vault file: %w", err) + } return nil } +func (c *InitInstallConfigCmd) collectConfiguration() (*files.CollectedConfig, error) { + var collected *files.CollectedConfig + var err error + + if c.Opts.Interactive { + collected, err = c.Generator.CollectInteractively() + } else { + configOpts := c.buildConfigOptions() + collected, err = c.Generator.CollectFromOptions(configOpts) + } + if err != nil { + return nil, fmt.Errorf("failed to collect configuration: %w", err) + } + + return collected, nil +} + func (c *InitInstallConfigCmd) buildConfigOptions() *files.ConfigOptions { return &files.ConfigOptions{ DatacenterID: c.Opts.DatacenterID, @@ -193,6 +203,12 @@ func (c *InitInstallConfigCmd) buildConfigOptions() *files.ConfigOptions { } } +func (c *InitInstallConfigCmd) printWelcomeMessage() { + fmt.Println("Welcome to OMS!") + fmt.Println("This wizard will help you create config.yaml and prod.vault.yaml for Codesphere installation.") + fmt.Println() +} + func (c *InitInstallConfigCmd) printSuccessMessage() { fmt.Println("\n" + strings.Repeat("=", 70)) fmt.Println("Configuration files successfully generated!") @@ -241,7 +257,6 @@ func (c *InitInstallConfigCmd) applyProfile() error { 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") @@ -310,7 +325,6 @@ func (c *InitInstallConfigCmd) applyProfile() error { 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") @@ -433,7 +447,7 @@ func AddInitInstallConfigCmd(init *cobra.Command, opts *GlobalOptions) { util.MarkFlagRequired(c.cmd, "vault") c.cmd.PreRun = func(cmd *cobra.Command, args []string) { - c.Generator = installer.NewConfigGenerator(c.Opts.Interactive) + c.Generator = installer.NewConfigGenerator() } c.cmd.RunE = c.RunE diff --git a/internal/installer/collector.go b/internal/installer/collector.go index a02bee18..ef34f284 100644 --- a/internal/installer/collector.go +++ b/internal/installer/collector.go @@ -44,25 +44,6 @@ func (g *InstallConfig) collectChoice(prompter *Prompter, optValue, prompt strin }) } -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) @@ -76,12 +57,8 @@ func (g *InstallConfig) collectRegistryConfig(prompter *Prompter, opts *files.Co 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) - } + collected.RegistryReplaceImages = prompter.Bool("Replace images in BOM", opts.RegistryReplaceImages) + collected.RegistryLoadContainerImgs = prompter.Bool("Load container images from installer", opts.RegistryLoadContainerImgs) } } @@ -93,15 +70,10 @@ func (g *InstallConfig) collectPostgresConfig(prompter *Prompter, opts *files.Co 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 + hasReplica := prompter.Bool("Configure PostgreSQL replica", opts.PostgresReplicaIP != "") + 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.PgExternal = g.collectString(prompter, opts.PostgresExternal, "External PostgreSQL server address", "postgres.example.com:5432") @@ -131,10 +103,7 @@ func (g *InstallConfig) collectCephConfig(prompter *Prompter, opts *files.Config 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) - } + collected.K8sManaged = prompter.Bool("Use Codesphere-managed Kubernetes (k0s)", opts.K8sManaged) if collected.K8sManaged { collected.K8sAPIServer = g.collectString(prompter, opts.K8sAPIServer, "Kubernetes API server host (LB/DNS/IP)", "10.50.0.2") @@ -148,45 +117,50 @@ func (g *InstallConfig) collectK8sConfig(prompter *Prompter, opts *files.ConfigO } 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, - } - } + + collected.MetalLBEnabled = prompter.Bool("Enable MetalLB", opts.MetalLBEnabled) + + if collected.MetalLBEnabled { + defaultNumPools := len(opts.MetalLBPools) + if defaultNumPools == 0 { + defaultNumPools = 1 } - } 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) + numPools := prompter.Int("Number of MetalLB IP pools", defaultNumPools) + + collected.MetalLBPools = make([]files.MetalLBPoolDef, numPools) + for i := 0; i < numPools; i++ { + fmt.Printf("\nMetalLB Pool %d:\n", i+1) + + defaultName := fmt.Sprintf("pool-%d", i+1) + var defaultIPs []string + if i < len(opts.MetalLBPools) { + defaultName = opts.MetalLBPools[i].Name + defaultIPs = opts.MetalLBPools[i].IPAddresses + } + if len(defaultIPs) == 0 { + defaultIPs = []string{"10.10.10.100-10.10.10.200"} + } + + poolName := prompter.String(" Pool name", defaultName) + poolIPs := prompter.StringSlice(" IP addresses/ranges (comma-separated)", defaultIPs) + collected.MetalLBPools[i] = files.MetalLBPoolDef{ + Name: poolName, + IPAddresses: poolIPs, + } } } } diff --git a/internal/installer/config_manager.go b/internal/installer/config_manager.go index 8d46cdc0..80f3ddb8 100644 --- a/internal/installer/config_manager.go +++ b/internal/installer/config_manager.go @@ -13,44 +13,60 @@ import ( ) type InstallConfigManager interface { - CollectConfiguration(opts *files.ConfigOptions) (*files.RootConfig, error) - WriteConfigAndVault(configPath, vaultPath string, withComments bool) error + CollectInteractively() (*files.CollectedConfig, error) + + CollectFromOptions(opts *files.ConfigOptions) (*files.CollectedConfig, error) + + ConvertToConfig(collected *files.CollectedConfig) (*files.RootConfig, error) + + GenerateSecrets(config *files.RootConfig) error + + WriteConfig(config *files.RootConfig, configPath string, withComments bool) error + + WriteVault(config *files.RootConfig, vaultPath string, withComments bool) error } type InstallConfig struct { - Interactive bool - configOpts *files.ConfigOptions - config *files.RootConfig - fileIO util.FileIO + fileIO util.FileIO } -func NewConfigGenerator(interactive bool) InstallConfigManager { +func NewConfigGenerator() InstallConfigManager { return &InstallConfig{ - Interactive: interactive, - fileIO: &util.FilesystemWriter{}, + 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) - } +func (g *InstallConfig) CollectInteractively() (*files.CollectedConfig, error) { + prompter := NewPrompter(true) + collected := &files.CollectedConfig{} + g.collectAllConfigs(prompter, &files.ConfigOptions{}, collected) + return collected, nil +} - config, err := g.convertConfig(collectedOpts) - if err != nil { - return nil, fmt.Errorf("failed to convert configuration: %w", err) - } +func (g *InstallConfig) CollectFromOptions(opts *files.ConfigOptions) (*files.CollectedConfig, error) { + prompter := NewPrompter(false) + collected := &files.CollectedConfig{} + g.collectAllConfigs(prompter, opts, collected) + return collected, nil +} - if err := g.generateSecrets(config); err != nil { - return nil, fmt.Errorf("failed to generate secrets: %w", err) - } +func (g *InstallConfig) collectAllConfigs(prompter *Prompter, opts *files.ConfigOptions, collected *files.CollectedConfig) { + 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) +} - g.config = config +func (g *InstallConfig) ConvertToConfig(collected *files.CollectedConfig) (*files.RootConfig, error) { + return g.convertConfig(collected) +} - return config, nil +func (g *InstallConfig) GenerateSecrets(config *files.RootConfig) error { + return g.generateSecrets(config) } func (g *InstallConfig) convertConfig(collected *files.CollectedConfig) (*files.RootConfig, error) { @@ -213,12 +229,12 @@ func (g *InstallConfig) convertConfig(collected *files.CollectedConfig) (*files. 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") +func (g *InstallConfig) WriteConfig(config *files.RootConfig, configPath string, withComments bool) error { + if config == nil { + return fmt.Errorf("no configuration provided - config is nil") } - configYAML, err := MarshalConfig(g.config) + configYAML, err := MarshalConfig(config) if err != nil { return fmt.Errorf("failed to marshal config.yaml: %w", err) } @@ -231,7 +247,15 @@ func (g *InstallConfig) WriteConfigAndVault(configPath, vaultPath string, withCo return err } - vault := g.config.ExtractVault() + return nil +} + +func (g *InstallConfig) WriteVault(config *files.RootConfig, vaultPath string, withComments bool) error { + if config == nil { + return fmt.Errorf("no configuration provided - config is nil") + } + + vault := config.ExtractVault() vaultYAML, err := MarshalVault(vault) if err != nil { return fmt.Errorf("failed to marshal vault.yaml: %w", err) diff --git a/internal/installer/mocks.go b/internal/installer/mocks.go index 3e96b7ef..18592eab 100644 --- a/internal/installer/mocks.go +++ b/internal/installer/mocks.go @@ -118,24 +118,24 @@ 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) { +// CollectFromOptions provides a mock function for the type MockInstallConfigManager +func (_mock *MockInstallConfigManager) CollectFromOptions(opts *files.ConfigOptions) (*files.CollectedConfig, error) { ret := _mock.Called(opts) if len(ret) == 0 { - panic("no return value specified for CollectConfiguration") + panic("no return value specified for CollectFromOptions") } - var r0 *files.RootConfig + var r0 *files.CollectedConfig var r1 error - if returnFunc, ok := ret.Get(0).(func(*files.ConfigOptions) (*files.RootConfig, error)); ok { + if returnFunc, ok := ret.Get(0).(func(*files.ConfigOptions) (*files.CollectedConfig, error)); ok { return returnFunc(opts) } - if returnFunc, ok := ret.Get(0).(func(*files.ConfigOptions) *files.RootConfig); ok { + if returnFunc, ok := ret.Get(0).(func(*files.ConfigOptions) *files.CollectedConfig); ok { r0 = returnFunc(opts) } else { if ret.Get(0) != nil { - r0 = ret.Get(0).(*files.RootConfig) + r0 = ret.Get(0).(*files.CollectedConfig) } } if returnFunc, ok := ret.Get(1).(func(*files.ConfigOptions) error); ok { @@ -146,77 +146,280 @@ func (_mock *MockInstallConfigManager) CollectConfiguration(opts *files.ConfigOp 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 { +// MockInstallConfigManager_CollectFromOptions_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CollectFromOptions' +type MockInstallConfigManager_CollectFromOptions_Call struct { *mock.Call } -// CollectConfiguration is a helper method to define mock.On call +// CollectFromOptions 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 (_e *MockInstallConfigManager_Expecter) CollectFromOptions(opts interface{}) *MockInstallConfigManager_CollectFromOptions_Call { + return &MockInstallConfigManager_CollectFromOptions_Call{Call: _e.mock.On("CollectFromOptions", opts)} } -func (_c *MockInstallConfigManager_CollectConfiguration_Call) Run(run func(opts *files.ConfigOptions)) *MockInstallConfigManager_CollectConfiguration_Call { +func (_c *MockInstallConfigManager_CollectFromOptions_Call) Run(run func(opts *files.ConfigOptions)) *MockInstallConfigManager_CollectFromOptions_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 { +func (_c *MockInstallConfigManager_CollectFromOptions_Call) Return(collectedConfig *files.CollectedConfig, err error) *MockInstallConfigManager_CollectFromOptions_Call { + _c.Call.Return(collectedConfig, err) + return _c +} + +func (_c *MockInstallConfigManager_CollectFromOptions_Call) RunAndReturn(run func(opts *files.ConfigOptions) (*files.CollectedConfig, error)) *MockInstallConfigManager_CollectFromOptions_Call { + _c.Call.Return(run) + return _c +} + +// CollectInteractively provides a mock function for the type MockInstallConfigManager +func (_mock *MockInstallConfigManager) CollectInteractively() (*files.CollectedConfig, error) { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for CollectInteractively") + } + + var r0 *files.CollectedConfig + var r1 error + if returnFunc, ok := ret.Get(0).(func() (*files.CollectedConfig, error)); ok { + return returnFunc() + } + if returnFunc, ok := ret.Get(0).(func() *files.CollectedConfig); ok { + r0 = returnFunc() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*files.CollectedConfig) + } + } + if returnFunc, ok := ret.Get(1).(func() error); ok { + r1 = returnFunc() + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockInstallConfigManager_CollectInteractively_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CollectInteractively' +type MockInstallConfigManager_CollectInteractively_Call struct { + *mock.Call +} + +// CollectInteractively is a helper method to define mock.On call +func (_e *MockInstallConfigManager_Expecter) CollectInteractively() *MockInstallConfigManager_CollectInteractively_Call { + return &MockInstallConfigManager_CollectInteractively_Call{Call: _e.mock.On("CollectInteractively")} +} + +func (_c *MockInstallConfigManager_CollectInteractively_Call) Run(run func()) *MockInstallConfigManager_CollectInteractively_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockInstallConfigManager_CollectInteractively_Call) Return(collectedConfig *files.CollectedConfig, err error) *MockInstallConfigManager_CollectInteractively_Call { + _c.Call.Return(collectedConfig, err) + return _c +} + +func (_c *MockInstallConfigManager_CollectInteractively_Call) RunAndReturn(run func() (*files.CollectedConfig, error)) *MockInstallConfigManager_CollectInteractively_Call { + _c.Call.Return(run) + return _c +} + +// ConvertToConfig provides a mock function for the type MockInstallConfigManager +func (_mock *MockInstallConfigManager) ConvertToConfig(collected *files.CollectedConfig) (*files.RootConfig, error) { + ret := _mock.Called(collected) + + if len(ret) == 0 { + panic("no return value specified for ConvertToConfig") + } + + var r0 *files.RootConfig + var r1 error + if returnFunc, ok := ret.Get(0).(func(*files.CollectedConfig) (*files.RootConfig, error)); ok { + return returnFunc(collected) + } + if returnFunc, ok := ret.Get(0).(func(*files.CollectedConfig) *files.RootConfig); ok { + r0 = returnFunc(collected) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*files.RootConfig) + } + } + if returnFunc, ok := ret.Get(1).(func(*files.CollectedConfig) error); ok { + r1 = returnFunc(collected) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockInstallConfigManager_ConvertToConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ConvertToConfig' +type MockInstallConfigManager_ConvertToConfig_Call struct { + *mock.Call +} + +// ConvertToConfig is a helper method to define mock.On call +// - collected +func (_e *MockInstallConfigManager_Expecter) ConvertToConfig(collected interface{}) *MockInstallConfigManager_ConvertToConfig_Call { + return &MockInstallConfigManager_ConvertToConfig_Call{Call: _e.mock.On("ConvertToConfig", collected)} +} + +func (_c *MockInstallConfigManager_ConvertToConfig_Call) Run(run func(collected *files.CollectedConfig)) *MockInstallConfigManager_ConvertToConfig_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(*files.CollectedConfig)) + }) + return _c +} + +func (_c *MockInstallConfigManager_ConvertToConfig_Call) Return(rootConfig *files.RootConfig, err error) *MockInstallConfigManager_ConvertToConfig_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 { +func (_c *MockInstallConfigManager_ConvertToConfig_Call) RunAndReturn(run func(collected *files.CollectedConfig) (*files.RootConfig, error)) *MockInstallConfigManager_ConvertToConfig_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) +// GenerateSecrets provides a mock function for the type MockInstallConfigManager +func (_mock *MockInstallConfigManager) GenerateSecrets(config *files.RootConfig) error { + ret := _mock.Called(config) if len(ret) == 0 { - panic("no return value specified for WriteConfigAndVault") + panic("no return value specified for GenerateSecrets") } var r0 error - if returnFunc, ok := ret.Get(0).(func(string, string, bool) error); ok { - r0 = returnFunc(configPath, vaultPath, withComments) + if returnFunc, ok := ret.Get(0).(func(*files.RootConfig) error); ok { + r0 = returnFunc(config) } 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 { +// MockInstallConfigManager_GenerateSecrets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GenerateSecrets' +type MockInstallConfigManager_GenerateSecrets_Call struct { *mock.Call } -// WriteConfigAndVault is a helper method to define mock.On call +// GenerateSecrets is a helper method to define mock.On call +// - config +func (_e *MockInstallConfigManager_Expecter) GenerateSecrets(config interface{}) *MockInstallConfigManager_GenerateSecrets_Call { + return &MockInstallConfigManager_GenerateSecrets_Call{Call: _e.mock.On("GenerateSecrets", config)} +} + +func (_c *MockInstallConfigManager_GenerateSecrets_Call) Run(run func(config *files.RootConfig)) *MockInstallConfigManager_GenerateSecrets_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(*files.RootConfig)) + }) + return _c +} + +func (_c *MockInstallConfigManager_GenerateSecrets_Call) Return(err error) *MockInstallConfigManager_GenerateSecrets_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockInstallConfigManager_GenerateSecrets_Call) RunAndReturn(run func(config *files.RootConfig) error) *MockInstallConfigManager_GenerateSecrets_Call { + _c.Call.Return(run) + return _c +} + +// WriteConfig provides a mock function for the type MockInstallConfigManager +func (_mock *MockInstallConfigManager) WriteConfig(config *files.RootConfig, configPath string, withComments bool) error { + ret := _mock.Called(config, configPath, withComments) + + if len(ret) == 0 { + panic("no return value specified for WriteConfig") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(*files.RootConfig, string, bool) error); ok { + r0 = returnFunc(config, configPath, withComments) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockInstallConfigManager_WriteConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WriteConfig' +type MockInstallConfigManager_WriteConfig_Call struct { + *mock.Call +} + +// WriteConfig is a helper method to define mock.On call +// - config // - configPath +// - withComments +func (_e *MockInstallConfigManager_Expecter) WriteConfig(config interface{}, configPath interface{}, withComments interface{}) *MockInstallConfigManager_WriteConfig_Call { + return &MockInstallConfigManager_WriteConfig_Call{Call: _e.mock.On("WriteConfig", config, configPath, withComments)} +} + +func (_c *MockInstallConfigManager_WriteConfig_Call) Run(run func(config *files.RootConfig, configPath string, withComments bool)) *MockInstallConfigManager_WriteConfig_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(*files.RootConfig), args[1].(string), args[2].(bool)) + }) + return _c +} + +func (_c *MockInstallConfigManager_WriteConfig_Call) Return(err error) *MockInstallConfigManager_WriteConfig_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockInstallConfigManager_WriteConfig_Call) RunAndReturn(run func(config *files.RootConfig, configPath string, withComments bool) error) *MockInstallConfigManager_WriteConfig_Call { + _c.Call.Return(run) + return _c +} + +// WriteVault provides a mock function for the type MockInstallConfigManager +func (_mock *MockInstallConfigManager) WriteVault(config *files.RootConfig, vaultPath string, withComments bool) error { + ret := _mock.Called(config, vaultPath, withComments) + + if len(ret) == 0 { + panic("no return value specified for WriteVault") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(*files.RootConfig, string, bool) error); ok { + r0 = returnFunc(config, vaultPath, withComments) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockInstallConfigManager_WriteVault_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WriteVault' +type MockInstallConfigManager_WriteVault_Call struct { + *mock.Call +} + +// WriteVault is a helper method to define mock.On call +// - config // - 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 (_e *MockInstallConfigManager_Expecter) WriteVault(config interface{}, vaultPath interface{}, withComments interface{}) *MockInstallConfigManager_WriteVault_Call { + return &MockInstallConfigManager_WriteVault_Call{Call: _e.mock.On("WriteVault", config, vaultPath, withComments)} } -func (_c *MockInstallConfigManager_WriteConfigAndVault_Call) Run(run func(configPath string, vaultPath string, withComments bool)) *MockInstallConfigManager_WriteConfigAndVault_Call { +func (_c *MockInstallConfigManager_WriteVault_Call) Run(run func(config *files.RootConfig, vaultPath string, withComments bool)) *MockInstallConfigManager_WriteVault_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(string), args[1].(string), args[2].(bool)) + run(args[0].(*files.RootConfig), args[1].(string), args[2].(bool)) }) return _c } -func (_c *MockInstallConfigManager_WriteConfigAndVault_Call) Return(err error) *MockInstallConfigManager_WriteConfigAndVault_Call { +func (_c *MockInstallConfigManager_WriteVault_Call) Return(err error) *MockInstallConfigManager_WriteVault_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 { +func (_c *MockInstallConfigManager_WriteVault_Call) RunAndReturn(run func(config *files.RootConfig, vaultPath string, withComments bool) error) *MockInstallConfigManager_WriteVault_Call { _c.Call.Return(run) return _c } From 2e33700277ebf42e96e8e45d263fc08dfdcce5d8 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Tue, 11 Nov 2025 14:14:09 +0000 Subject: [PATCH 12/24] chore(docs): Auto-update docs and licenses Signed-off-by: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> --- docs/README.md | 1 + docs/oms-cli.md | 1 + docs/oms-cli_init.md | 1 - docs/oms-cli_init_install-config.md | 1 - 4 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/README.md b/docs/README.md index a172e71e..64087115 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,6 +20,7 @@ like downloading new versions. * [oms-cli beta](oms-cli_beta.md) - Commands for early testing * [oms-cli build](oms-cli_build.md) - Build and push images to a registry * [oms-cli download](oms-cli_download.md) - Download resources available through OMS +* [oms-cli init](oms-cli_init.md) - Initialize configuration files * [oms-cli install](oms-cli_install.md) - Coming soon: Install Codesphere and other components * [oms-cli licenses](oms-cli_licenses.md) - Print license information * [oms-cli list](oms-cli_list.md) - List resources available through OMS diff --git a/docs/oms-cli.md b/docs/oms-cli.md index a172e71e..64087115 100644 --- a/docs/oms-cli.md +++ b/docs/oms-cli.md @@ -20,6 +20,7 @@ like downloading new versions. * [oms-cli beta](oms-cli_beta.md) - Commands for early testing * [oms-cli build](oms-cli_build.md) - Build and push images to a registry * [oms-cli download](oms-cli_download.md) - Download resources available through OMS +* [oms-cli init](oms-cli_init.md) - Initialize configuration files * [oms-cli install](oms-cli_install.md) - Coming soon: Install Codesphere and other components * [oms-cli licenses](oms-cli_licenses.md) - Print license information * [oms-cli list](oms-cli_list.md) - List resources available through OMS diff --git a/docs/oms-cli_init.md b/docs/oms-cli_init.md index cba6c7b2..08b8b3bb 100644 --- a/docs/oms-cli_init.md +++ b/docs/oms-cli_init.md @@ -17,4 +17,3 @@ Initialize configuration files for Codesphere installation and other components. * [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 index f3830440..72a49d44 100644 --- a/docs/oms-cli_init_install-config.md +++ b/docs/oms-cli_init_install-config.md @@ -64,4 +64,3 @@ $ oms-cli init install-config --validate -c config.yaml -v prod.vault.yaml * [oms-cli init](oms-cli_init.md) - Initialize configuration files -###### Auto generated by spf13/cobra on 6-Nov-2025 From 4657793254926cbcffc67b2c309f513d0e43f71e Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Wed, 12 Nov 2025 09:54:06 +0100 Subject: [PATCH 13/24] ref: InstallConfigManager interface and implementation --- cli/cmd/init_install_config.go | 286 +++++++++++------------ internal/installer/config_manager.go | 335 ++++++++++++++++++++++----- internal/installer/mocks.go | 259 ++++++++++----------- 3 files changed, 534 insertions(+), 346 deletions(-) diff --git a/cli/cmd/init_install_config.go b/cli/cmd/init_install_config.go index 65f2e68d..015c15ff 100644 --- a/cli/cmd/init_install_config.go +++ b/cli/cmd/init_install_config.go @@ -89,118 +89,163 @@ func (c *InitInstallConfigCmd) RunE(_ *cobra.Command, args []string) error { return c.validateConfig() } - if c.Opts.Profile != "" { - if err := c.applyProfile(); err != nil { - return fmt.Errorf("failed to apply profile: %w", err) - } - } - - c.printWelcomeMessage() + return c.InitInstallConfig() +} - if err := c.createConfig(); err != nil { - return err +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.printSuccessMessage() + 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") - return nil -} + 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") -func (c *InitInstallConfigCmd) createConfig() error { - collected, err := c.collectConfiguration() - if err != nil { - return err - } + c.cmd.Flags().IntVar(&c.Opts.DatacenterID, "dc-id", 0, "Datacenter ID") + c.cmd.Flags().StringVar(&c.Opts.DatacenterName, "dc-name", "", "Datacenter name") - config, err := c.Generator.ConvertToConfig(collected) - if err != nil { - return fmt.Errorf("failed to convert configuration: %w", err) - } + 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") - if err := c.Generator.GenerateSecrets(config); err != nil { - return fmt.Errorf("failed to generate secrets: %w", err) - } + 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)") - if err := c.Generator.WriteConfig(config, c.Opts.ConfigFile, c.Opts.WithComments); err != nil { - return fmt.Errorf("failed to write config file: %w", err) - } + c.cmd.Flags().StringVar(&c.Opts.CodesphereDomain, "domain", "", "Main Codesphere domain") - if err := c.Generator.WriteVault(config, c.Opts.VaultFile, c.Opts.WithComments); err != nil { - return fmt.Errorf("failed to write vault file: %w", err) + util.MarkFlagRequired(c.cmd, "config") + util.MarkFlagRequired(c.cmd, "vault") + + c.cmd.PreRun = func(cmd *cobra.Command, args []string) { + c.Generator = installer.NewConfigGenerator() } - return nil + c.cmd.RunE = c.RunE + init.AddCommand(c.cmd) } -func (c *InitInstallConfigCmd) collectConfiguration() (*files.CollectedConfig, error) { - var collected *files.CollectedConfig +func (c *InitInstallConfigCmd) InitInstallConfig() error { var err error + c.printWelcomeMessage() + + if c.Opts.Profile != "" { + if err := c.applyProfile(); err != nil { + return fmt.Errorf("failed to apply profile: %w", err) + } + } + if c.Opts.Interactive { - collected, err = c.Generator.CollectInteractively() + err = c.Generator.CollectInteractively() + if err != nil { + return fmt.Errorf("failed to collect configuration interactively: %w", err) + } } else { - configOpts := c.buildConfigOptions() - collected, err = c.Generator.CollectFromOptions(configOpts) + config, err := installer.FromConfigOptions(&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, + }) + if err != nil { + return fmt.Errorf("failed to build configuration from options: %w", err) + } + c.Generator.SetConfig(config) } - if err != nil { - return nil, fmt.Errorf("failed to collect configuration: %w", err) + + if err := c.Generator.Validate(); err != nil { + return fmt.Errorf("configuration validation failed: %w", err) } - return collected, nil -} + if err := c.Generator.GenerateSecrets(); err != nil { + return fmt.Errorf("failed to generate secrets: %w", err) + } + + if err := c.Generator.WriteInstallConfig(c.Opts.ConfigFile, c.Opts.WithComments); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } -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, + if err := c.Generator.WriteVault(c.Opts.VaultFile, c.Opts.WithComments); err != nil { + return fmt.Errorf("failed to write vault file: %w", err) } + + c.printSuccessMessage() + + return nil } func (c *InitInstallConfigCmd) printWelcomeMessage() { @@ -392,64 +437,3 @@ func (c *InitInstallConfigCmd) validateConfig() error { 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.cmd.RunE = c.RunE - init.AddCommand(c.cmd) -} diff --git a/internal/installer/config_manager.go b/internal/installer/config_manager.go index 80f3ddb8..e9720088 100644 --- a/internal/installer/config_manager.go +++ b/internal/installer/config_manager.go @@ -6,6 +6,7 @@ package installer import ( "fmt" "net" + "strings" "github.com/codesphere-cloud/oms/internal/installer/files" "github.com/codesphere-cloud/oms/internal/util" @@ -13,41 +14,298 @@ import ( ) type InstallConfigManager interface { - CollectInteractively() (*files.CollectedConfig, error) + CollectInteractively() error - CollectFromOptions(opts *files.ConfigOptions) (*files.CollectedConfig, error) + SetConfig(config *files.RootConfig) - ConvertToConfig(collected *files.CollectedConfig) (*files.RootConfig, error) + SetProfileValues(profile string) error - GenerateSecrets(config *files.RootConfig) error + Validate() error - WriteConfig(config *files.RootConfig, configPath string, withComments bool) error + GenerateSecrets() error - WriteVault(config *files.RootConfig, vaultPath string, withComments bool) error + WriteInstallConfig(configPath string, withComments bool) error + + WriteVault(vaultPath string, withComments bool) error } type InstallConfig struct { fileIO util.FileIO + config *files.RootConfig } func NewConfigGenerator() InstallConfigManager { return &InstallConfig{ fileIO: &util.FilesystemWriter{}, + config: nil, } } -func (g *InstallConfig) CollectInteractively() (*files.CollectedConfig, error) { +func (g *InstallConfig) CollectInteractively() error { prompter := NewPrompter(true) collected := &files.CollectedConfig{} g.collectAllConfigs(prompter, &files.ConfigOptions{}, collected) - return collected, nil + + config, err := g.convertConfig(collected) + if err != nil { + return fmt.Errorf("failed to convert configuration: %w", err) + } + + g.config = config + return nil } -func (g *InstallConfig) CollectFromOptions(opts *files.ConfigOptions) (*files.CollectedConfig, error) { - prompter := NewPrompter(false) - collected := &files.CollectedConfig{} - g.collectAllConfigs(prompter, opts, collected) - return collected, nil +func (g *InstallConfig) SetConfig(config *files.RootConfig) { + g.config = config +} + +func (g *InstallConfig) SetProfileValues(profile string) error { + if profile == "" { + return nil + } + + if g.config == nil { + return fmt.Errorf("config not set, cannot apply profile") + } + + return nil +} + +func (g *InstallConfig) Validate() error { + if g.config == nil { + return fmt.Errorf("config not set, cannot validate") + } + + errors := ValidateConfig(g.config) + if len(errors) > 0 { + var errMsg strings.Builder + errMsg.WriteString("configuration validation failed:\n") + for _, err := range errors { + errMsg.WriteString(fmt.Sprintf(" - %s\n", err)) + } + return fmt.Errorf("%s", errMsg.String()) + } + + return nil +} + +func (g *InstallConfig) GenerateSecrets() error { + if g.config == nil { + return fmt.Errorf("config not set, cannot generate secrets") + } + return g.generateSecrets(g.config) +} + +func (g *InstallConfig) WriteInstallConfig(configPath string, withComments bool) error { + if g.config == nil { + return fmt.Errorf("no configuration provided - config is nil") + } + + configYAML, err := 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 + } + + return nil +} + +func (g *InstallConfig) WriteVault(vaultPath string, withComments bool) error { + if g.config == nil { + return fmt.Errorf("no configuration provided - config is nil") + } + + vault := g.config.ExtractVault() + vaultYAML, err := 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 FromConfigOptions(opts *files.ConfigOptions) (*files.RootConfig, error) { + config := &files.RootConfig{ + DataCenter: files.DataCenterConfig{ + ID: opts.DatacenterID, + Name: opts.DatacenterName, + City: opts.DatacenterCity, + CountryCode: opts.DatacenterCountryCode, + }, + Secrets: files.SecretsConfig{ + BaseDir: opts.SecretsBaseDir, + }, + } + + if opts.RegistryServer != "" { + config.Registry = files.RegistryConfig{ + Server: opts.RegistryServer, + ReplaceImagesInBom: opts.RegistryReplaceImages, + LoadContainerImages: opts.RegistryLoadContainerImgs, + } + } + + if opts.PostgresMode == "install" { + config.Postgres = files.PostgresConfig{ + Primary: &files.PostgresPrimaryConfig{ + IP: opts.PostgresPrimaryIP, + Hostname: opts.PostgresPrimaryHost, + }, + } + + if opts.PostgresReplicaIP != "" { + config.Postgres.Replica = &files.PostgresReplicaConfig{ + IP: opts.PostgresReplicaIP, + Name: opts.PostgresReplicaName, + } + } + } else if opts.PostgresExternal != "" { + config.Postgres = files.PostgresConfig{ + ServerAddress: opts.PostgresExternal, + } + } + + cephHosts := make([]files.CephHost, len(opts.CephHosts)) + for i, host := range opts.CephHosts { + cephHosts[i] = files.CephHost(host) + } + + config.Ceph = files.CephConfig{ + NodesSubnet: opts.CephSubnet, + Hosts: 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: opts.K8sManaged, + } + + if opts.K8sManaged { + config.Kubernetes.APIServerHost = opts.K8sAPIServer + config.Kubernetes.ControlPlanes = make([]files.K8sNode, len(opts.K8sControlPlane)) + for i, ip := range opts.K8sControlPlane { + config.Kubernetes.ControlPlanes[i] = files.K8sNode{IPAddress: ip} + } + config.Kubernetes.Workers = make([]files.K8sNode, len(opts.K8sWorkers)) + for i, ip := range opts.K8sWorkers { + config.Kubernetes.Workers[i] = files.K8sNode{IPAddress: ip} + } + config.Kubernetes.NeedsKubeConfig = false + } else { + config.Kubernetes.PodCIDR = opts.K8sPodCIDR + config.Kubernetes.ServiceCIDR = opts.K8sServiceCIDR + config.Kubernetes.NeedsKubeConfig = true + } + + config.Cluster = files.ClusterConfig{ + Certificates: files.ClusterCertificates{ + CA: files.CAConfig{ + Algorithm: "RSA", + KeySizeBits: 2048, + }, + }, + Gateway: files.GatewayConfig{ + ServiceType: opts.ClusterGatewayType, + IPAddresses: opts.ClusterGatewayIPs, + }, + PublicGateway: files.GatewayConfig{ + ServiceType: opts.ClusterPublicGatewayType, + IPAddresses: opts.ClusterPublicGatewayIPs, + }, + } + + if opts.MetalLBEnabled { + pools := make([]files.MetalLBPoolDef, len(opts.MetalLBPools)) + for i, pool := range opts.MetalLBPools { + pools[i] = files.MetalLBPoolDef(pool) + } + config.MetalLB = &files.MetalLBConfig{ + Enabled: true, + Pools: pools, + } + } + + config.Codesphere = files.CodesphereConfig{ + Domain: opts.CodesphereDomain, + WorkspaceHostingBaseDomain: opts.CodesphereWorkspaceBaseDomain, + PublicIP: opts.CodespherePublicIP, + CustomDomains: files.CustomDomainsConfig{ + CNameBaseDomain: opts.CodesphereCustomDomainBaseDomain, + }, + DNSServers: opts.CodesphereDNSServers, + 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: opts.CodesphereWorkspaceImageBomRef, + }, + Pool: map[int]int{1: 1}, + }, + }, + }, + }, + }, + Plans: files.PlansConfig{ + HostingPlans: map[int]files.HostingPlan{ + 1: { + CPUTenth: opts.CodesphereHostingPlanCPU, + GPUParts: 0, + MemoryMb: opts.CodesphereHostingPlanMemory, + StorageMb: opts.CodesphereHostingPlanStorage, + TempStorageMb: opts.CodesphereHostingPlanTempStorage, + }, + }, + WorkspacePlans: map[int]files.WorkspacePlan{ + 1: { + Name: opts.CodesphereWorkspacePlanName, + HostingPlanID: 1, + MaxReplicas: opts.CodesphereWorkspacePlanMaxReplica, + OnDemand: true, + }, + }, + }, + } + + config.ManagedServiceBackends = &files.ManagedServiceBackendsConfig{ + Postgres: make(map[string]interface{}), + } + + return config, nil } func (g *InstallConfig) collectAllConfigs(prompter *Prompter, opts *files.ConfigOptions, collected *files.CollectedConfig) { @@ -61,14 +319,6 @@ func (g *InstallConfig) collectAllConfigs(prompter *Prompter, opts *files.Config g.collectCodesphereConfig(prompter, opts, collected) } -func (g *InstallConfig) ConvertToConfig(collected *files.CollectedConfig) (*files.RootConfig, error) { - return g.convertConfig(collected) -} - -func (g *InstallConfig) GenerateSecrets(config *files.RootConfig) error { - return g.generateSecrets(config) -} - func (g *InstallConfig) convertConfig(collected *files.CollectedConfig) (*files.RootConfig, error) { config := &files.RootConfig{ DataCenter: files.DataCenterConfig{ @@ -229,49 +479,6 @@ func (g *InstallConfig) convertConfig(collected *files.CollectedConfig) (*files. return config, nil } -func (g *InstallConfig) WriteConfig(config *files.RootConfig, configPath string, withComments bool) error { - if config == nil { - return fmt.Errorf("no configuration provided - config is nil") - } - - configYAML, err := MarshalConfig(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 - } - - return nil -} - -func (g *InstallConfig) WriteVault(config *files.RootConfig, vaultPath string, withComments bool) error { - if config == nil { - return fmt.Errorf("no configuration provided - config is nil") - } - - vault := 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 diff --git a/internal/installer/mocks.go b/internal/installer/mocks.go index 18592eab..71cf7454 100644 --- a/internal/installer/mocks.go +++ b/internal/installer/mocks.go @@ -118,276 +118,274 @@ func (_m *MockInstallConfigManager) EXPECT() *MockInstallConfigManager_Expecter return &MockInstallConfigManager_Expecter{mock: &_m.Mock} } -// CollectFromOptions provides a mock function for the type MockInstallConfigManager -func (_mock *MockInstallConfigManager) CollectFromOptions(opts *files.ConfigOptions) (*files.CollectedConfig, error) { - ret := _mock.Called(opts) +// CollectInteractively provides a mock function for the type MockInstallConfigManager +func (_mock *MockInstallConfigManager) CollectInteractively() error { + ret := _mock.Called() if len(ret) == 0 { - panic("no return value specified for CollectFromOptions") + panic("no return value specified for CollectInteractively") } - var r0 *files.CollectedConfig - var r1 error - if returnFunc, ok := ret.Get(0).(func(*files.ConfigOptions) (*files.CollectedConfig, error)); ok { - return returnFunc(opts) - } - if returnFunc, ok := ret.Get(0).(func(*files.ConfigOptions) *files.CollectedConfig); ok { - r0 = returnFunc(opts) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*files.CollectedConfig) - } - } - if returnFunc, ok := ret.Get(1).(func(*files.ConfigOptions) error); ok { - r1 = returnFunc(opts) + var r0 error + if returnFunc, ok := ret.Get(0).(func() error); ok { + r0 = returnFunc() } else { - r1 = ret.Error(1) + r0 = ret.Error(0) } - return r0, r1 + return r0 } -// MockInstallConfigManager_CollectFromOptions_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CollectFromOptions' -type MockInstallConfigManager_CollectFromOptions_Call struct { +// MockInstallConfigManager_CollectInteractively_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CollectInteractively' +type MockInstallConfigManager_CollectInteractively_Call struct { *mock.Call } -// CollectFromOptions is a helper method to define mock.On call -// - opts -func (_e *MockInstallConfigManager_Expecter) CollectFromOptions(opts interface{}) *MockInstallConfigManager_CollectFromOptions_Call { - return &MockInstallConfigManager_CollectFromOptions_Call{Call: _e.mock.On("CollectFromOptions", opts)} +// CollectInteractively is a helper method to define mock.On call +func (_e *MockInstallConfigManager_Expecter) CollectInteractively() *MockInstallConfigManager_CollectInteractively_Call { + return &MockInstallConfigManager_CollectInteractively_Call{Call: _e.mock.On("CollectInteractively")} } -func (_c *MockInstallConfigManager_CollectFromOptions_Call) Run(run func(opts *files.ConfigOptions)) *MockInstallConfigManager_CollectFromOptions_Call { +func (_c *MockInstallConfigManager_CollectInteractively_Call) Run(run func()) *MockInstallConfigManager_CollectInteractively_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(*files.ConfigOptions)) + run() }) return _c } -func (_c *MockInstallConfigManager_CollectFromOptions_Call) Return(collectedConfig *files.CollectedConfig, err error) *MockInstallConfigManager_CollectFromOptions_Call { - _c.Call.Return(collectedConfig, err) +func (_c *MockInstallConfigManager_CollectInteractively_Call) Return(err error) *MockInstallConfigManager_CollectInteractively_Call { + _c.Call.Return(err) return _c } -func (_c *MockInstallConfigManager_CollectFromOptions_Call) RunAndReturn(run func(opts *files.ConfigOptions) (*files.CollectedConfig, error)) *MockInstallConfigManager_CollectFromOptions_Call { +func (_c *MockInstallConfigManager_CollectInteractively_Call) RunAndReturn(run func() error) *MockInstallConfigManager_CollectInteractively_Call { _c.Call.Return(run) return _c } -// CollectInteractively provides a mock function for the type MockInstallConfigManager -func (_mock *MockInstallConfigManager) CollectInteractively() (*files.CollectedConfig, error) { +// GenerateSecrets provides a mock function for the type MockInstallConfigManager +func (_mock *MockInstallConfigManager) GenerateSecrets() error { ret := _mock.Called() if len(ret) == 0 { - panic("no return value specified for CollectInteractively") + panic("no return value specified for GenerateSecrets") } - var r0 *files.CollectedConfig - var r1 error - if returnFunc, ok := ret.Get(0).(func() (*files.CollectedConfig, error)); ok { - return returnFunc() - } - if returnFunc, ok := ret.Get(0).(func() *files.CollectedConfig); ok { + var r0 error + if returnFunc, ok := ret.Get(0).(func() error); ok { r0 = returnFunc() } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*files.CollectedConfig) - } - } - if returnFunc, ok := ret.Get(1).(func() error); ok { - r1 = returnFunc() - } else { - r1 = ret.Error(1) + r0 = ret.Error(0) } - return r0, r1 + return r0 } -// MockInstallConfigManager_CollectInteractively_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CollectInteractively' -type MockInstallConfigManager_CollectInteractively_Call struct { +// MockInstallConfigManager_GenerateSecrets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GenerateSecrets' +type MockInstallConfigManager_GenerateSecrets_Call struct { *mock.Call } -// CollectInteractively is a helper method to define mock.On call -func (_e *MockInstallConfigManager_Expecter) CollectInteractively() *MockInstallConfigManager_CollectInteractively_Call { - return &MockInstallConfigManager_CollectInteractively_Call{Call: _e.mock.On("CollectInteractively")} +// GenerateSecrets is a helper method to define mock.On call +func (_e *MockInstallConfigManager_Expecter) GenerateSecrets() *MockInstallConfigManager_GenerateSecrets_Call { + return &MockInstallConfigManager_GenerateSecrets_Call{Call: _e.mock.On("GenerateSecrets")} } -func (_c *MockInstallConfigManager_CollectInteractively_Call) Run(run func()) *MockInstallConfigManager_CollectInteractively_Call { +func (_c *MockInstallConfigManager_GenerateSecrets_Call) Run(run func()) *MockInstallConfigManager_GenerateSecrets_Call { _c.Call.Run(func(args mock.Arguments) { run() }) return _c } -func (_c *MockInstallConfigManager_CollectInteractively_Call) Return(collectedConfig *files.CollectedConfig, err error) *MockInstallConfigManager_CollectInteractively_Call { - _c.Call.Return(collectedConfig, err) +func (_c *MockInstallConfigManager_GenerateSecrets_Call) Return(err error) *MockInstallConfigManager_GenerateSecrets_Call { + _c.Call.Return(err) return _c } -func (_c *MockInstallConfigManager_CollectInteractively_Call) RunAndReturn(run func() (*files.CollectedConfig, error)) *MockInstallConfigManager_CollectInteractively_Call { +func (_c *MockInstallConfigManager_GenerateSecrets_Call) RunAndReturn(run func() error) *MockInstallConfigManager_GenerateSecrets_Call { _c.Call.Return(run) return _c } -// ConvertToConfig provides a mock function for the type MockInstallConfigManager -func (_mock *MockInstallConfigManager) ConvertToConfig(collected *files.CollectedConfig) (*files.RootConfig, error) { - ret := _mock.Called(collected) +// SetConfig provides a mock function for the type MockInstallConfigManager +func (_mock *MockInstallConfigManager) SetConfig(config *files.RootConfig) { + _mock.Called(config) + return +} + +// MockInstallConfigManager_SetConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetConfig' +type MockInstallConfigManager_SetConfig_Call struct { + *mock.Call +} + +// SetConfig is a helper method to define mock.On call +// - config +func (_e *MockInstallConfigManager_Expecter) SetConfig(config interface{}) *MockInstallConfigManager_SetConfig_Call { + return &MockInstallConfigManager_SetConfig_Call{Call: _e.mock.On("SetConfig", config)} +} + +func (_c *MockInstallConfigManager_SetConfig_Call) Run(run func(config *files.RootConfig)) *MockInstallConfigManager_SetConfig_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(*files.RootConfig)) + }) + return _c +} + +func (_c *MockInstallConfigManager_SetConfig_Call) Return() *MockInstallConfigManager_SetConfig_Call { + _c.Call.Return() + return _c +} + +func (_c *MockInstallConfigManager_SetConfig_Call) RunAndReturn(run func(config *files.RootConfig)) *MockInstallConfigManager_SetConfig_Call { + _c.Run(run) + return _c +} + +// SetProfileValues provides a mock function for the type MockInstallConfigManager +func (_mock *MockInstallConfigManager) SetProfileValues(profile string) error { + ret := _mock.Called(profile) if len(ret) == 0 { - panic("no return value specified for ConvertToConfig") + panic("no return value specified for SetProfileValues") } - var r0 *files.RootConfig - var r1 error - if returnFunc, ok := ret.Get(0).(func(*files.CollectedConfig) (*files.RootConfig, error)); ok { - return returnFunc(collected) - } - if returnFunc, ok := ret.Get(0).(func(*files.CollectedConfig) *files.RootConfig); ok { - r0 = returnFunc(collected) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*files.RootConfig) - } - } - if returnFunc, ok := ret.Get(1).(func(*files.CollectedConfig) error); ok { - r1 = returnFunc(collected) + var r0 error + if returnFunc, ok := ret.Get(0).(func(string) error); ok { + r0 = returnFunc(profile) } else { - r1 = ret.Error(1) + r0 = ret.Error(0) } - return r0, r1 + return r0 } -// MockInstallConfigManager_ConvertToConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ConvertToConfig' -type MockInstallConfigManager_ConvertToConfig_Call struct { +// MockInstallConfigManager_SetProfileValues_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetProfileValues' +type MockInstallConfigManager_SetProfileValues_Call struct { *mock.Call } -// ConvertToConfig is a helper method to define mock.On call -// - collected -func (_e *MockInstallConfigManager_Expecter) ConvertToConfig(collected interface{}) *MockInstallConfigManager_ConvertToConfig_Call { - return &MockInstallConfigManager_ConvertToConfig_Call{Call: _e.mock.On("ConvertToConfig", collected)} +// SetProfileValues is a helper method to define mock.On call +// - profile +func (_e *MockInstallConfigManager_Expecter) SetProfileValues(profile interface{}) *MockInstallConfigManager_SetProfileValues_Call { + return &MockInstallConfigManager_SetProfileValues_Call{Call: _e.mock.On("SetProfileValues", profile)} } -func (_c *MockInstallConfigManager_ConvertToConfig_Call) Run(run func(collected *files.CollectedConfig)) *MockInstallConfigManager_ConvertToConfig_Call { +func (_c *MockInstallConfigManager_SetProfileValues_Call) Run(run func(profile string)) *MockInstallConfigManager_SetProfileValues_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(*files.CollectedConfig)) + run(args[0].(string)) }) return _c } -func (_c *MockInstallConfigManager_ConvertToConfig_Call) Return(rootConfig *files.RootConfig, err error) *MockInstallConfigManager_ConvertToConfig_Call { - _c.Call.Return(rootConfig, err) +func (_c *MockInstallConfigManager_SetProfileValues_Call) Return(err error) *MockInstallConfigManager_SetProfileValues_Call { + _c.Call.Return(err) return _c } -func (_c *MockInstallConfigManager_ConvertToConfig_Call) RunAndReturn(run func(collected *files.CollectedConfig) (*files.RootConfig, error)) *MockInstallConfigManager_ConvertToConfig_Call { +func (_c *MockInstallConfigManager_SetProfileValues_Call) RunAndReturn(run func(profile string) error) *MockInstallConfigManager_SetProfileValues_Call { _c.Call.Return(run) return _c } -// GenerateSecrets provides a mock function for the type MockInstallConfigManager -func (_mock *MockInstallConfigManager) GenerateSecrets(config *files.RootConfig) error { - ret := _mock.Called(config) +// Validate provides a mock function for the type MockInstallConfigManager +func (_mock *MockInstallConfigManager) Validate() error { + ret := _mock.Called() if len(ret) == 0 { - panic("no return value specified for GenerateSecrets") + panic("no return value specified for Validate") } var r0 error - if returnFunc, ok := ret.Get(0).(func(*files.RootConfig) error); ok { - r0 = returnFunc(config) + if returnFunc, ok := ret.Get(0).(func() error); ok { + r0 = returnFunc() } else { r0 = ret.Error(0) } return r0 } -// MockInstallConfigManager_GenerateSecrets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GenerateSecrets' -type MockInstallConfigManager_GenerateSecrets_Call struct { +// MockInstallConfigManager_Validate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Validate' +type MockInstallConfigManager_Validate_Call struct { *mock.Call } -// GenerateSecrets is a helper method to define mock.On call -// - config -func (_e *MockInstallConfigManager_Expecter) GenerateSecrets(config interface{}) *MockInstallConfigManager_GenerateSecrets_Call { - return &MockInstallConfigManager_GenerateSecrets_Call{Call: _e.mock.On("GenerateSecrets", config)} +// Validate is a helper method to define mock.On call +func (_e *MockInstallConfigManager_Expecter) Validate() *MockInstallConfigManager_Validate_Call { + return &MockInstallConfigManager_Validate_Call{Call: _e.mock.On("Validate")} } -func (_c *MockInstallConfigManager_GenerateSecrets_Call) Run(run func(config *files.RootConfig)) *MockInstallConfigManager_GenerateSecrets_Call { +func (_c *MockInstallConfigManager_Validate_Call) Run(run func()) *MockInstallConfigManager_Validate_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(*files.RootConfig)) + run() }) return _c } -func (_c *MockInstallConfigManager_GenerateSecrets_Call) Return(err error) *MockInstallConfigManager_GenerateSecrets_Call { +func (_c *MockInstallConfigManager_Validate_Call) Return(err error) *MockInstallConfigManager_Validate_Call { _c.Call.Return(err) return _c } -func (_c *MockInstallConfigManager_GenerateSecrets_Call) RunAndReturn(run func(config *files.RootConfig) error) *MockInstallConfigManager_GenerateSecrets_Call { +func (_c *MockInstallConfigManager_Validate_Call) RunAndReturn(run func() error) *MockInstallConfigManager_Validate_Call { _c.Call.Return(run) return _c } -// WriteConfig provides a mock function for the type MockInstallConfigManager -func (_mock *MockInstallConfigManager) WriteConfig(config *files.RootConfig, configPath string, withComments bool) error { - ret := _mock.Called(config, configPath, withComments) +// WriteInstallConfig provides a mock function for the type MockInstallConfigManager +func (_mock *MockInstallConfigManager) WriteInstallConfig(configPath string, withComments bool) error { + ret := _mock.Called(configPath, withComments) if len(ret) == 0 { - panic("no return value specified for WriteConfig") + panic("no return value specified for WriteInstallConfig") } var r0 error - if returnFunc, ok := ret.Get(0).(func(*files.RootConfig, string, bool) error); ok { - r0 = returnFunc(config, configPath, withComments) + if returnFunc, ok := ret.Get(0).(func(string, bool) error); ok { + r0 = returnFunc(configPath, withComments) } else { r0 = ret.Error(0) } return r0 } -// MockInstallConfigManager_WriteConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WriteConfig' -type MockInstallConfigManager_WriteConfig_Call struct { +// MockInstallConfigManager_WriteInstallConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WriteInstallConfig' +type MockInstallConfigManager_WriteInstallConfig_Call struct { *mock.Call } -// WriteConfig is a helper method to define mock.On call -// - config +// WriteInstallConfig is a helper method to define mock.On call // - configPath // - withComments -func (_e *MockInstallConfigManager_Expecter) WriteConfig(config interface{}, configPath interface{}, withComments interface{}) *MockInstallConfigManager_WriteConfig_Call { - return &MockInstallConfigManager_WriteConfig_Call{Call: _e.mock.On("WriteConfig", config, configPath, withComments)} +func (_e *MockInstallConfigManager_Expecter) WriteInstallConfig(configPath interface{}, withComments interface{}) *MockInstallConfigManager_WriteInstallConfig_Call { + return &MockInstallConfigManager_WriteInstallConfig_Call{Call: _e.mock.On("WriteInstallConfig", configPath, withComments)} } -func (_c *MockInstallConfigManager_WriteConfig_Call) Run(run func(config *files.RootConfig, configPath string, withComments bool)) *MockInstallConfigManager_WriteConfig_Call { +func (_c *MockInstallConfigManager_WriteInstallConfig_Call) Run(run func(configPath string, withComments bool)) *MockInstallConfigManager_WriteInstallConfig_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(*files.RootConfig), args[1].(string), args[2].(bool)) + run(args[0].(string), args[1].(bool)) }) return _c } -func (_c *MockInstallConfigManager_WriteConfig_Call) Return(err error) *MockInstallConfigManager_WriteConfig_Call { +func (_c *MockInstallConfigManager_WriteInstallConfig_Call) Return(err error) *MockInstallConfigManager_WriteInstallConfig_Call { _c.Call.Return(err) return _c } -func (_c *MockInstallConfigManager_WriteConfig_Call) RunAndReturn(run func(config *files.RootConfig, configPath string, withComments bool) error) *MockInstallConfigManager_WriteConfig_Call { +func (_c *MockInstallConfigManager_WriteInstallConfig_Call) RunAndReturn(run func(configPath string, withComments bool) error) *MockInstallConfigManager_WriteInstallConfig_Call { _c.Call.Return(run) return _c } // WriteVault provides a mock function for the type MockInstallConfigManager -func (_mock *MockInstallConfigManager) WriteVault(config *files.RootConfig, vaultPath string, withComments bool) error { - ret := _mock.Called(config, vaultPath, withComments) +func (_mock *MockInstallConfigManager) WriteVault(vaultPath string, withComments bool) error { + ret := _mock.Called(vaultPath, withComments) if len(ret) == 0 { panic("no return value specified for WriteVault") } var r0 error - if returnFunc, ok := ret.Get(0).(func(*files.RootConfig, string, bool) error); ok { - r0 = returnFunc(config, vaultPath, withComments) + if returnFunc, ok := ret.Get(0).(func(string, bool) error); ok { + r0 = returnFunc(vaultPath, withComments) } else { r0 = ret.Error(0) } @@ -400,16 +398,15 @@ type MockInstallConfigManager_WriteVault_Call struct { } // WriteVault is a helper method to define mock.On call -// - config // - vaultPath // - withComments -func (_e *MockInstallConfigManager_Expecter) WriteVault(config interface{}, vaultPath interface{}, withComments interface{}) *MockInstallConfigManager_WriteVault_Call { - return &MockInstallConfigManager_WriteVault_Call{Call: _e.mock.On("WriteVault", config, vaultPath, withComments)} +func (_e *MockInstallConfigManager_Expecter) WriteVault(vaultPath interface{}, withComments interface{}) *MockInstallConfigManager_WriteVault_Call { + return &MockInstallConfigManager_WriteVault_Call{Call: _e.mock.On("WriteVault", vaultPath, withComments)} } -func (_c *MockInstallConfigManager_WriteVault_Call) Run(run func(config *files.RootConfig, vaultPath string, withComments bool)) *MockInstallConfigManager_WriteVault_Call { +func (_c *MockInstallConfigManager_WriteVault_Call) Run(run func(vaultPath string, withComments bool)) *MockInstallConfigManager_WriteVault_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(*files.RootConfig), args[1].(string), args[2].(bool)) + run(args[0].(string), args[1].(bool)) }) return _c } @@ -419,7 +416,7 @@ func (_c *MockInstallConfigManager_WriteVault_Call) Return(err error) *MockInsta return _c } -func (_c *MockInstallConfigManager_WriteVault_Call) RunAndReturn(run func(config *files.RootConfig, vaultPath string, withComments bool) error) *MockInstallConfigManager_WriteVault_Call { +func (_c *MockInstallConfigManager_WriteVault_Call) RunAndReturn(run func(vaultPath string, withComments bool) error) *MockInstallConfigManager_WriteVault_Call { _c.Call.Return(run) return _c } From 470bbc8f6b5cdabbb9341a2891fff5cad9978398 Mon Sep 17 00:00:00 2001 From: Simon Herrmann Date: Wed, 12 Nov 2025 16:03:29 +0100 Subject: [PATCH 14/24] refac: refactor generator Co-authored-by: OliverTrautvetter --- cli/cmd/init_install_config.go | 404 ++++++------ internal/installer/collector.go | 184 ------ .../installer/config_generator_collector.go | 211 +++++++ internal/installer/config_manager.go | 591 ++++++------------ internal/installer/config_manager_profile.go | 188 ++++++ internal/installer/files/config_yaml.go | 154 +---- 6 files changed, 828 insertions(+), 904 deletions(-) delete mode 100644 internal/installer/collector.go create mode 100644 internal/installer/config_generator_collector.go create mode 100644 internal/installer/config_manager_profile.go diff --git a/cli/cmd/init_install_config.go b/cli/cmd/init_install_config.go index 015c15ff..6f57b28e 100644 --- a/cli/cmd/init_install_config.go +++ b/cli/cmd/init_install_config.go @@ -19,7 +19,6 @@ type InitInstallConfigCmd struct { cmd *cobra.Command Opts *InitInstallConfigOpts FileWriter util.FileIO - Generator installer.InstallConfigManager } type InitInstallConfigOpts struct { @@ -85,11 +84,9 @@ type InitInstallConfigOpts struct { } func (c *InitInstallConfigCmd) RunE(_ *cobra.Command, args []string) error { - if c.Opts.ValidateOnly { - return c.validateConfig() - } + icg := installer.NewInstallConfigManager() - return c.InitInstallConfig() + return c.InitInstallConfig(icg) } func AddInitInstallConfigCmd(init *cobra.Command, opts *GlobalOptions) { @@ -145,101 +142,67 @@ func AddInitInstallConfigCmd(init *cobra.Command, opts *GlobalOptions) { util.MarkFlagRequired(c.cmd, "config") util.MarkFlagRequired(c.cmd, "vault") - c.cmd.PreRun = func(cmd *cobra.Command, args []string) { - c.Generator = installer.NewConfigGenerator() - } - c.cmd.RunE = c.RunE init.AddCommand(c.cmd) } -func (c *InitInstallConfigCmd) InitInstallConfig() error { - var err error - - c.printWelcomeMessage() +func (c *InitInstallConfigCmd) InitInstallConfig(icg installer.InstallConfigManager) error { + // Validation only mode + if c.Opts.ValidateOnly { + // TODO: put into validateOnly method + err := icg.LoadConfigFromFile(c.Opts.ConfigFile) + if err != nil { + return fmt.Errorf("failed to load config file: %w", err) + } - if c.Opts.Profile != "" { - if err := c.applyProfile(); err != nil { - return fmt.Errorf("failed to apply profile: %w", err) + err = icg.Validate() + if err != nil { + return fmt.Errorf("configuration validation failed: %w", err) } + + // err = icg.LoadVaultFromFile(c.Opts.VaultFile) + // if err != nil { + // return fmt.Errorf("failed to load vault file: %w", err) + // } + + // err = icg.ValidateVault() + // if err != nil { + // return fmt.Errorf("vault validation failed: %w", err) + // } + + return nil + } + + // Generate new configuration from either Opts or interactively + err := icg.ApplyProfile(c.Opts.Profile) + if err != nil { + return fmt.Errorf("failed to apply profile: %w", err) } + c.printWelcomeMessage() + if c.Opts.Interactive { - err = c.Generator.CollectInteractively() + err = icg.CollectInteractively() if err != nil { return fmt.Errorf("failed to collect configuration interactively: %w", err) } } else { - config, err := installer.FromConfigOptions(&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, - }) - if err != nil { - return fmt.Errorf("failed to build configuration from options: %w", err) - } - c.Generator.SetConfig(config) + c.updateConfigFromOpts(icg.GetConfig()) } - if err := c.Generator.Validate(); err != nil { + if err := icg.Validate(); err != nil { return fmt.Errorf("configuration validation failed: %w", err) } - if err := c.Generator.GenerateSecrets(); err != nil { + if err := icg.GenerateSecrets(); err != nil { return fmt.Errorf("failed to generate secrets: %w", err) } - if err := c.Generator.WriteInstallConfig(c.Opts.ConfigFile, c.Opts.WithComments); err != nil { + if err := icg.WriteInstallConfig(c.Opts.ConfigFile, c.Opts.WithComments); err != nil { return fmt.Errorf("failed to write config file: %w", err) } - if err := c.Generator.WriteVault(c.Opts.VaultFile, c.Opts.WithComments); err != nil { + if err := icg.WriteVault(c.Opts.VaultFile, c.Opts.WithComments); err != nil { return fmt.Errorf("failed to write vault file: %w", err) } @@ -273,136 +236,24 @@ func (c *InitInstallConfigCmd) printSuccessMessage() { 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.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.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 { +func (c *InitInstallConfigCmd) validateOnly(icg installer.InstallConfigManager) 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) - } + // TODO: Check if config file can be empty + if c.Opts.ConfigFile != "" { + fmt.Printf("Reading config file: %s\n", c.Opts.ConfigFile) + err := icg.LoadConfigFromFile(c.Opts.ConfigFile) + if err != nil { + return fmt.Errorf("failed to load config file: %w", err) + } - config, err := installer.UnmarshalConfig(configData) - if err != nil { - return fmt.Errorf("failed to parse config.yaml: %w", err) + err = icg.Validate() + if err != nil { + return fmt.Errorf("configuration validation failed: %w", err) + } } - errors := installer.ValidateConfig(config) - + var errors []string if c.Opts.VaultFile != "" { fmt.Printf("Reading vault file: %s\n", c.Opts.VaultFile) vaultFile, err := c.FileWriter.Open(c.Opts.VaultFile) @@ -437,3 +288,154 @@ func (c *InitInstallConfigCmd) validateConfig() error { fmt.Println("Configuration is valid!") return nil } + +func (c *InitInstallConfigCmd) updateConfigFromOpts(config *files.RootConfig) *files.RootConfig { + // Datacenter settings + config.Datacenter.ID = c.Opts.DatacenterID + config.Datacenter.City = c.Opts.DatacenterCity + config.Datacenter.CountryCode = c.Opts.DatacenterCountryCode + config.Datacenter.Name = c.Opts.DatacenterName + + // Registry settings + config.Registry.LoadContainerImages = c.Opts.RegistryLoadContainerImgs + config.Registry.ReplaceImagesInBom = c.Opts.RegistryReplaceImages + config.Registry.Server = c.Opts.RegistryServer + + // Postgres settings + if c.Opts.PostgresExternal != "" { + config.Postgres.ServerAddress = c.Opts.PostgresExternal + } + + if c.Opts.PostgresPrimaryHost != "" && c.Opts.PostgresPrimaryIP != "" { + if config.Postgres.Primary == nil { + // TODO: Mode: c.Opts.PostgresMode, + // TODO: External: c.Opts.PostgresExternal, + config.Postgres.Primary = &files.PostgresPrimaryConfig{ + Hostname: c.Opts.PostgresPrimaryHost, + IP: c.Opts.PostgresPrimaryIP, + } + } else { + // TODO: Mode: c.Opts.PostgresMode, + // TODO: External: c.Opts.PostgresExternal, + config.Postgres.Primary.Hostname = c.Opts.PostgresPrimaryHost + config.Postgres.Primary.IP = c.Opts.PostgresPrimaryIP + } + } + + if c.Opts.PostgresReplicaIP != "" && c.Opts.PostgresReplicaName != "" { + if config.Postgres.Replica == nil { + // TODO: Mode: c.Opts.PostgresMode, + // TODO: External: c.Opts.PostgresExternal, + config.Postgres.Replica = &files.PostgresReplicaConfig{ + Name: c.Opts.PostgresReplicaName, + IP: c.Opts.PostgresReplicaIP, + } + } else { + // TODO: Mode: c.Opts.PostgresMode, + // TODO: External: c.Opts.PostgresExternal, + config.Postgres.Replica.Name = c.Opts.PostgresReplicaName + config.Postgres.Replica.IP = c.Opts.PostgresReplicaIP + } + } + + // Ceph settings + config.Ceph.NodesSubnet = c.Opts.CephSubnet + cephHosts := []files.CephHost{} + for _, hostCfg := range c.Opts.CephHosts { + cephHosts = append(config.Ceph.Hosts, files.CephHost{ + Hostname: hostCfg.Hostname, + IPAddress: hostCfg.IPAddress, + IsMaster: hostCfg.IsMaster, + }) + } + if len(cephHosts) > 0 { + config.Ceph.Hosts = cephHosts + } + + // Kubernetes settings + config.Kubernetes.ManagedByCodesphere = c.Opts.K8sManaged + config.Kubernetes.APIServerHost = c.Opts.K8sAPIServer + config.Kubernetes.PodCIDR = c.Opts.K8sPodCIDR + config.Kubernetes.ServiceCIDR = c.Opts.K8sServiceCIDR + + kubernetesControlPlanes := []files.K8sNode{} + for _, ip := range c.Opts.K8sControlPlane { + kubernetesControlPlanes = append(kubernetesControlPlanes, files.K8sNode{ + IPAddress: ip, + }) + } + config.Kubernetes.ControlPlanes = kubernetesControlPlanes + + kubernetesWorkers := []files.K8sNode{} + for _, ip := range c.Opts.K8sWorkers { + kubernetesWorkers = append(kubernetesWorkers, files.K8sNode{ + IPAddress: ip, + }) + } + config.Kubernetes.Workers = kubernetesWorkers + + // Cluster Gateway settings + config.Cluster.Gateway.ServiceType = c.Opts.ClusterGatewayType + config.Cluster.Gateway.IPAddresses = c.Opts.ClusterGatewayIPs + config.Cluster.PublicGateway.ServiceType = c.Opts.ClusterPublicGatewayType + config.Cluster.PublicGateway.IPAddresses = c.Opts.ClusterPublicGatewayIPs + + // MetalLB settings + if c.Opts.MetalLBEnabled { + if config.MetalLB == nil { + config.MetalLB = &files.MetalLBConfig{ + Enabled: c.Opts.MetalLBEnabled, + Pools: []files.MetalLBPoolDef{}, + } + } else { + config.MetalLB.Enabled = c.Opts.MetalLBEnabled + config.MetalLB.Pools = []files.MetalLBPoolDef{} + } + + for _, pool := range c.Opts.MetalLBPools { + config.MetalLB.Pools = append(config.MetalLB.Pools, files.MetalLBPoolDef{ + Name: pool.Name, + IPAddresses: pool.IPAddresses, + // TODO: ARPEnabled: pool.ARPEnabled, + }) + } + } + + // Codesphere settings + config.Codesphere.Domain = c.Opts.CodesphereDomain + config.Codesphere.PublicIP = c.Opts.CodespherePublicIP + config.Codesphere.WorkspaceHostingBaseDomain = c.Opts.CodesphereWorkspaceBaseDomain + config.Codesphere.CustomDomains = files.CustomDomainsConfig{CNameBaseDomain: c.Opts.CodesphereCustomDomainBaseDomain} + config.Codesphere.DNSServers = c.Opts.CodesphereDNSServers + + if config.Codesphere.WorkspaceImages == nil { + config.Codesphere.WorkspaceImages = &files.WorkspaceImagesConfig{} + } + config.Codesphere.WorkspaceImages.Agent = &files.ImageRef{ + BomRef: c.Opts.CodesphereWorkspaceImageBomRef, + } + + config.Codesphere.Plans = files.PlansConfig{ + HostingPlans: map[int]files.HostingPlan{ + 1: { + CPUTenth: c.Opts.CodesphereHostingPlanCPU, + MemoryMb: c.Opts.CodesphereHostingPlanMemory, + StorageMb: c.Opts.CodesphereHostingPlanStorage, + TempStorageMb: c.Opts.CodesphereHostingPlanTempStorage, + }, + }, + WorkspacePlans: map[int]files.WorkspacePlan{ + 1: { + Name: c.Opts.CodesphereWorkspacePlanName, + HostingPlanID: 1, + MaxReplicas: c.Opts.CodesphereWorkspacePlanMaxReplica, + OnDemand: true, + }, + }, + } + + // Secrets base dir + config.Secrets.BaseDir = c.Opts.SecretsBaseDir + + return config +} diff --git a/internal/installer/collector.go b/internal/installer/collector.go deleted file mode 100644 index ef34f284..00000000 --- a/internal/installer/collector.go +++ /dev/null @@ -1,184 +0,0 @@ -// 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) 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 = prompter.Bool("Replace images in BOM", opts.RegistryReplaceImages) - collected.RegistryLoadContainerImgs = prompter.Bool("Load container images from installer", opts.RegistryLoadContainerImgs) - } -} - -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") - - hasReplica := prompter.Bool("Configure PostgreSQL replica", opts.PostgresReplicaIP != "") - 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.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 = prompter.Bool("Use Codesphere-managed Kubernetes (k0s)", opts.K8sManaged) - - 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) { - 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"}) - } - - 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"}) - } -} - -func (g *InstallConfig) collectMetalLBConfig(prompter *Prompter, opts *files.ConfigOptions, collected *files.CollectedConfig) { - fmt.Println("\n=== MetalLB Configuration (Optional) ===") - - collected.MetalLBEnabled = prompter.Bool("Enable MetalLB", opts.MetalLBEnabled) - - if collected.MetalLBEnabled { - defaultNumPools := len(opts.MetalLBPools) - if defaultNumPools == 0 { - defaultNumPools = 1 - } - numPools := prompter.Int("Number of MetalLB IP pools", defaultNumPools) - - collected.MetalLBPools = make([]files.MetalLBPoolDef, numPools) - for i := 0; i < numPools; i++ { - fmt.Printf("\nMetalLB Pool %d:\n", i+1) - - defaultName := fmt.Sprintf("pool-%d", i+1) - var defaultIPs []string - if i < len(opts.MetalLBPools) { - defaultName = opts.MetalLBPools[i].Name - defaultIPs = opts.MetalLBPools[i].IPAddresses - } - if len(defaultIPs) == 0 { - defaultIPs = []string{"10.10.10.100-10.10.10.200"} - } - - poolName := prompter.String(" Pool name", defaultName) - poolIPs := prompter.StringSlice(" IP addresses/ranges (comma-separated)", defaultIPs) - collected.MetalLBPools[i] = files.MetalLBPoolDef{ - Name: poolName, - IPAddresses: poolIPs, - } - } - } -} - -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_generator_collector.go b/internal/installer/config_generator_collector.go new file mode 100644 index 00000000..54a93c12 --- /dev/null +++ b/internal/installer/config_generator_collector.go @@ -0,0 +1,211 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "fmt" + + "github.com/codesphere-cloud/oms/internal/installer/files" +) + +func (g *InstallConfig) CollectInteractively() error { + prompter := NewPrompter(true) + + g.collectDatacenterConfig(prompter) + g.collectRegistryConfig(prompter) + g.collectPostgresConfig(prompter) + g.collectCephConfig(prompter) + g.collectK8sConfig(prompter) + g.collectGatewayConfig(prompter) + g.collectMetalLBConfig(prompter) + g.collectCodesphereConfig(prompter) + + return nil +} + +func collectField[T any](isEmpty func(T) bool, promptFunc func() T) T { + return promptFunc() +} + +func isEmptyString(s string) bool { return s == "" } +func isEmptyInt(i int) bool { return i == 0 } +func isEmptySlice(s []string) bool { return len(s) == 0 } + +func (g *InstallConfig) collectString(prompter *Prompter, prompt, defaultVal string) string { + return collectField(isEmptyString, func() string { + return prompter.String(prompt, defaultVal) + }) +} + +func (g *InstallConfig) collectInt(prompter *Prompter, prompt string, defaultVal int) int { + return collectField(isEmptyInt, func() int { + return prompter.Int(prompt, defaultVal) + }) +} + +func (g *InstallConfig) collectStringSlice(prompter *Prompter, prompt string, defaultVal []string) []string { + return collectField(isEmptySlice, func() []string { + return prompter.StringSlice(prompt, defaultVal) + }) +} + +func (g *InstallConfig) collectChoice(prompter *Prompter, prompt string, options []string, defaultVal string) string { + return collectField(isEmptyString, func() string { + return prompter.Choice(prompt, options, defaultVal) + }) +} + +func (g *InstallConfig) collectDatacenterConfig(prompter *Prompter) { + fmt.Println("=== Datacenter Configuration ===") + g.Config.Datacenter.ID = g.collectInt(prompter, "Datacenter ID", g.Config.Datacenter.ID) + g.Config.Datacenter.Name = g.collectString(prompter, "Datacenter name", g.Config.Datacenter.Name) + g.Config.Datacenter.City = g.collectString(prompter, "Datacenter city", g.Config.Datacenter.City) + g.Config.Datacenter.CountryCode = g.collectString(prompter, "Country code", g.Config.Datacenter.CountryCode) + g.Config.Secrets.BaseDir = g.collectString(prompter, "Secrets base directory", "/root/secrets") +} + +func (g *InstallConfig) collectRegistryConfig(prompter *Prompter) { + fmt.Println("\n=== Container Registry Configuration ===") + g.Config.Registry.Server = g.collectString(prompter, "Container registry server (e.g., ghcr.io, leave empty to skip)", "") + if g.Config.Registry.Server != "" { + g.Config.Registry.ReplaceImagesInBom = prompter.Bool("Replace images in BOM", g.Config.Registry.ReplaceImagesInBom) + g.Config.Registry.LoadContainerImages = prompter.Bool("Load container images from installer", g.Config.Registry.LoadContainerImages) + } +} + +func (g *InstallConfig) collectPostgresConfig(prompter *Prompter) { + fmt.Println("\n=== PostgreSQL Configuration ===") + // // TODO: create mode in generator + // g.config.Postgres.Mode = g.collectChoice(prompter, "PostgreSQL setup", []string{"install", "external"}, "install") + + // if g.config.Postgres.Mode == "install" { + // g.config.Postgres.Primary.IP = g.collectString(prompter, "Primary PostgreSQL server IP", "10.50.0.2") + // g.config.Postgres.Primary.Hostname = g.collectString(prompter, "Primary PostgreSQL hostname", "pg-primary-node") + // hasReplica := prompter.Bool("Configure PostgreSQL replica", g.config.Postgres.Replica != nil) + // if hasReplica { + // g.config.Postgres.Replica.IP = g.collectString(prompter, "Replica PostgreSQL server IP", "10.50.0.3") + // g.config.Postgres.Replica.Name = g.collectString(prompter, "Replica name (lowercase alphanumeric + underscore only)", "replica1") + // } + // } else { + // g.config.Postgres.ServerAddress = g.collectString(prompter, "External PostgreSQL server address", "postgres.example.com:5432") + // } +} + +func (g *InstallConfig) collectCephConfig(prompter *Prompter) { + fmt.Println("\n=== Ceph Configuration ===") + g.Config.Ceph.NodesSubnet = g.collectString(prompter, "Ceph nodes subnet (CIDR)", "10.53.101.0/24") + + if len(g.Config.Ceph.Hosts) == 0 { + numHosts := prompter.Int("Number of Ceph hosts", 3) + g.Config.Ceph.Hosts = make([]files.CephHost, numHosts) + for i := 0; i < numHosts; i++ { + fmt.Printf("\nCeph Host %d:\n", i+1) + g.Config.Ceph.Hosts[i].Hostname = prompter.String(" Hostname (as shown by 'hostname' command)", fmt.Sprintf("ceph-node-%d", i)) + g.Config.Ceph.Hosts[i].IPAddress = prompter.String(" IP address", fmt.Sprintf("10.53.101.%d", i+2)) + g.Config.Ceph.Hosts[i].IsMaster = (i == 0) + } + } else { + g.Config.Ceph.Hosts = make([]files.CephHost, len(g.Config.Ceph.Hosts)) + for i, host := range g.Config.Ceph.Hosts { + g.Config.Ceph.Hosts[i] = files.CephHost(host) + } + } +} + +func (g *InstallConfig) collectK8sConfig(prompter *Prompter) { + fmt.Println("\n=== Kubernetes Configuration ===") + g.Config.Kubernetes.ManagedByCodesphere = prompter.Bool("Use Codesphere-managed Kubernetes (k0s)", g.Config.Kubernetes.ManagedByCodesphere) + + if g.Config.Kubernetes.ManagedByCodesphere { + g.Config.Kubernetes.APIServerHost = g.collectString(prompter, "Kubernetes API server host (LB/DNS/IP)", "10.50.0.2") + // TODO: convert existing config params to string array and after collection convert back + // g.config.Kubernetes.ControlPlanes = g.collectStringSlice(prompter, "Control plane IP addresses (comma-separated)", []string{"10.50.0.2"}) + // g.config.Kubernetes.Workers = g.collectStringSlice(prompter, "Worker node IP addresses (comma-separated)", []string{"10.50.0.2", "10.50.0.3", "10.50.0.4"}) + } else { + g.Config.Kubernetes.PodCIDR = g.collectString(prompter, "Pod CIDR of external cluster", "100.96.0.0/11") + g.Config.Kubernetes.ServiceCIDR = g.collectString(prompter, "Service CIDR of external cluster", "100.64.0.0/13") + fmt.Println("Note: You'll need to provide kubeconfig in the vault file for external Kubernetes") + } +} + +func (g *InstallConfig) collectGatewayConfig(prompter *Prompter) { + fmt.Println("\n=== Cluster Gateway Configuration ===") + g.Config.Cluster.Gateway.ServiceType = g.collectChoice(prompter, "Gateway service type", []string{"LoadBalancer", "ExternalIP"}, "LoadBalancer") + if g.Config.Cluster.Gateway.ServiceType == "ExternalIP" { + g.Config.Cluster.Gateway.IPAddresses = g.collectStringSlice(prompter, "Gateway IP addresses (comma-separated)", []string{"10.51.0.2", "10.51.0.3"}) + } + + g.Config.Cluster.PublicGateway.ServiceType = g.collectChoice(prompter, "Public gateway service type", []string{"LoadBalancer", "ExternalIP"}, "LoadBalancer") + if g.Config.Cluster.PublicGateway.ServiceType == "ExternalIP" { + g.Config.Cluster.PublicGateway.IPAddresses = g.collectStringSlice(prompter, "Public gateway IP addresses (comma-separated)", []string{"10.52.0.2", "10.52.0.3"}) + } +} + +func (g *InstallConfig) collectMetalLBConfig(prompter *Prompter) { + fmt.Println("\n=== MetalLB Configuration (Optional) ===") + + g.Config.MetalLB.Enabled = prompter.Bool("Enable MetalLB", g.Config.MetalLB.Enabled) + + if g.Config.MetalLB.Enabled { + defaultNumPools := len(g.Config.MetalLB.Pools) + if defaultNumPools == 0 { + defaultNumPools = 1 + } + numPools := prompter.Int("Number of MetalLB IP pools", defaultNumPools) + + g.Config.MetalLB.Pools = make([]files.MetalLBPoolDef, numPools) + for i := 0; i < numPools; i++ { + fmt.Printf("\nMetalLB Pool %d:\n", i+1) + + defaultName := fmt.Sprintf("pool-%d", i+1) + var defaultIPs []string + if i < len(g.Config.MetalLB.Pools) { + defaultName = g.Config.MetalLB.Pools[i].Name + defaultIPs = g.Config.MetalLB.Pools[i].IPAddresses + } + if len(defaultIPs) == 0 { + defaultIPs = []string{"10.10.10.100-10.10.10.200"} + } + + poolName := prompter.String(" Pool name", defaultName) + poolIPs := prompter.StringSlice(" IP addresses/ranges (comma-separated)", defaultIPs) + g.Config.MetalLB.Pools[i] = files.MetalLBPoolDef{ + Name: poolName, + IPAddresses: poolIPs, + } + } + } +} + +func (g *InstallConfig) collectCodesphereConfig(prompter *Prompter) { + fmt.Println("\n=== Codesphere Application Configuration ===") + g.Config.Codesphere.Domain = g.collectString(prompter, "Main Codesphere domain", "codesphere.yourcompany.com") + g.Config.Codesphere.WorkspaceHostingBaseDomain = g.collectString(prompter, "Workspace base domain (*.domain should point to public gateway)", "ws.yourcompany.com") + g.Config.Codesphere.PublicIP = g.collectString(prompter, "Primary public IP for workspaces", "") + g.Config.Codesphere.CustomDomains.CNameBaseDomain = g.collectString(prompter, "Custom domain CNAME base", "custom.yourcompany.com") + g.Config.Codesphere.DNSServers = g.collectStringSlice(prompter, "DNS servers (comma-separated)", []string{"1.1.1.1", "8.8.8.8"}) + + fmt.Println("\n=== Workspace Plans Configuration ===") + g.Config.Codesphere.WorkspaceImages.Agent.BomRef = g.collectString(prompter, "Workspace agent image BOM reference", "workspace-agent-24.04") + hostingPlan := files.HostingPlan{} + hostingPlan.CPUTenth = g.collectInt(prompter, "Hosting plan CPU (tenths, e.g., 10 = 1 core)", 10) + hostingPlan.MemoryMb = g.collectInt(prompter, "Hosting plan memory (MB)", 2048) + hostingPlan.StorageMb = g.collectInt(prompter, "Hosting plan storage (MB)", 20480) + hostingPlan.TempStorageMb = g.collectInt(prompter, "Hosting plan temp storage (MB)", 1024) + + workspacePlan := files.WorkspacePlan{ + HostingPlanID: 1, + } + workspacePlan.Name = g.collectString(prompter, "Workspace plan name", "Standard Developer") + workspacePlan.MaxReplicas = g.collectInt(prompter, "Max replicas per workspace", 3) + + g.Config.Codesphere.Plans = files.PlansConfig{ + HostingPlans: map[int]files.HostingPlan{ + 1: hostingPlan, + }, + WorkspacePlans: map[int]files.WorkspacePlan{ + 1: workspacePlan, + }, + } +} diff --git a/internal/installer/config_manager.go b/internal/installer/config_manager.go index e9720088..2504b4f6 100644 --- a/internal/installer/config_manager.go +++ b/internal/installer/config_manager.go @@ -5,6 +5,7 @@ package installer import ( "fmt" + "io" "net" "strings" @@ -14,69 +15,62 @@ import ( ) type InstallConfigManager interface { + // Profile management + ApplyProfile(profile string) error + // Configuration management + LoadConfigFromFile(configPath string) error + GetConfig() *files.RootConfig CollectInteractively() error - - SetConfig(config *files.RootConfig) - - SetProfileValues(profile string) error - Validate() error - + // Output GenerateSecrets() error - WriteInstallConfig(configPath string, withComments bool) error - WriteVault(vaultPath string, withComments bool) error } type InstallConfig struct { fileIO util.FileIO - config *files.RootConfig + Config *files.RootConfig } -func NewConfigGenerator() InstallConfigManager { +func NewInstallConfigManager() InstallConfigManager { return &InstallConfig{ fileIO: &util.FilesystemWriter{}, - config: nil, + Config: nil, } } -func (g *InstallConfig) CollectInteractively() error { - prompter := NewPrompter(true) - collected := &files.CollectedConfig{} - g.collectAllConfigs(prompter, &files.ConfigOptions{}, collected) - - config, err := g.convertConfig(collected) +func (g *InstallConfig) LoadConfigFromFile(configPath string) error { + file, err := g.fileIO.Open(configPath) if err != nil { - return fmt.Errorf("failed to convert configuration: %w", err) + return err } + defer util.CloseFileIgnoreError(file) - g.config = config - return nil -} - -func (g *InstallConfig) SetConfig(config *files.RootConfig) { - g.config = config -} - -func (g *InstallConfig) SetProfileValues(profile string) error { - if profile == "" { - return nil + data, err := io.ReadAll(file) + if err != nil { + return fmt.Errorf("failed to read %s: %w", configPath, err) } - if g.config == nil { - return fmt.Errorf("config not set, cannot apply profile") + config, err := UnmarshalConfig(data) + if err != nil { + return fmt.Errorf("failed to unmarshal %s: %w", configPath, err) } + g.Config = config return nil } +func (g *InstallConfig) GetConfig() *files.RootConfig { + return g.Config +} + func (g *InstallConfig) Validate() error { - if g.config == nil { + if g.Config == nil { return fmt.Errorf("config not set, cannot validate") } - errors := ValidateConfig(g.config) + errors := g.ValidateConfig() if len(errors) > 0 { var errMsg strings.Builder errMsg.WriteString("configuration validation failed:\n") @@ -90,18 +84,18 @@ func (g *InstallConfig) Validate() error { } func (g *InstallConfig) GenerateSecrets() error { - if g.config == nil { + if g.Config == nil { return fmt.Errorf("config not set, cannot generate secrets") } - return g.generateSecrets(g.config) + return g.generateSecrets(g.Config) } func (g *InstallConfig) WriteInstallConfig(configPath string, withComments bool) error { - if g.config == nil { + if g.Config == nil { return fmt.Errorf("no configuration provided - config is nil") } - configYAML, err := MarshalConfig(g.config) + configYAML, err := MarshalConfig(g.Config) if err != nil { return fmt.Errorf("failed to marshal config.yaml: %w", err) } @@ -118,11 +112,11 @@ func (g *InstallConfig) WriteInstallConfig(configPath string, withComments bool) } func (g *InstallConfig) WriteVault(vaultPath string, withComments bool) error { - if g.config == nil { + if g.Config == nil { return fmt.Errorf("no configuration provided - config is nil") } - vault := g.config.ExtractVault() + vault := g.Config.ExtractVault() vaultYAML, err := MarshalVault(vault) if err != nil { return fmt.Errorf("failed to marshal vault.yaml: %w", err) @@ -139,345 +133,166 @@ func (g *InstallConfig) WriteVault(vaultPath string, withComments bool) error { return nil } -func FromConfigOptions(opts *files.ConfigOptions) (*files.RootConfig, error) { - config := &files.RootConfig{ - DataCenter: files.DataCenterConfig{ - ID: opts.DatacenterID, - Name: opts.DatacenterName, - City: opts.DatacenterCity, - CountryCode: opts.DatacenterCountryCode, - }, - Secrets: files.SecretsConfig{ - BaseDir: opts.SecretsBaseDir, - }, - } - - if opts.RegistryServer != "" { - config.Registry = files.RegistryConfig{ - Server: opts.RegistryServer, - ReplaceImagesInBom: opts.RegistryReplaceImages, - LoadContainerImages: opts.RegistryLoadContainerImgs, - } - } - - if opts.PostgresMode == "install" { - config.Postgres = files.PostgresConfig{ - Primary: &files.PostgresPrimaryConfig{ - IP: opts.PostgresPrimaryIP, - Hostname: opts.PostgresPrimaryHost, - }, - } - - if opts.PostgresReplicaIP != "" { - config.Postgres.Replica = &files.PostgresReplicaConfig{ - IP: opts.PostgresReplicaIP, - Name: opts.PostgresReplicaName, - } - } - } else if opts.PostgresExternal != "" { - config.Postgres = files.PostgresConfig{ - ServerAddress: opts.PostgresExternal, - } - } - - cephHosts := make([]files.CephHost, len(opts.CephHosts)) - for i, host := range opts.CephHosts { - cephHosts[i] = files.CephHost(host) - } - - config.Ceph = files.CephConfig{ - NodesSubnet: opts.CephSubnet, - Hosts: 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: opts.K8sManaged, - } - - if opts.K8sManaged { - config.Kubernetes.APIServerHost = opts.K8sAPIServer - config.Kubernetes.ControlPlanes = make([]files.K8sNode, len(opts.K8sControlPlane)) - for i, ip := range opts.K8sControlPlane { - config.Kubernetes.ControlPlanes[i] = files.K8sNode{IPAddress: ip} - } - config.Kubernetes.Workers = make([]files.K8sNode, len(opts.K8sWorkers)) - for i, ip := range opts.K8sWorkers { - config.Kubernetes.Workers[i] = files.K8sNode{IPAddress: ip} - } - config.Kubernetes.NeedsKubeConfig = false - } else { - config.Kubernetes.PodCIDR = opts.K8sPodCIDR - config.Kubernetes.ServiceCIDR = opts.K8sServiceCIDR - config.Kubernetes.NeedsKubeConfig = true - } - - config.Cluster = files.ClusterConfig{ - Certificates: files.ClusterCertificates{ - CA: files.CAConfig{ - Algorithm: "RSA", - KeySizeBits: 2048, - }, - }, - Gateway: files.GatewayConfig{ - ServiceType: opts.ClusterGatewayType, - IPAddresses: opts.ClusterGatewayIPs, - }, - PublicGateway: files.GatewayConfig{ - ServiceType: opts.ClusterPublicGatewayType, - IPAddresses: opts.ClusterPublicGatewayIPs, - }, - } - - if opts.MetalLBEnabled { - pools := make([]files.MetalLBPoolDef, len(opts.MetalLBPools)) - for i, pool := range opts.MetalLBPools { - pools[i] = files.MetalLBPoolDef(pool) - } - config.MetalLB = &files.MetalLBConfig{ - Enabled: true, - Pools: pools, - } - } - - config.Codesphere = files.CodesphereConfig{ - Domain: opts.CodesphereDomain, - WorkspaceHostingBaseDomain: opts.CodesphereWorkspaceBaseDomain, - PublicIP: opts.CodespherePublicIP, - CustomDomains: files.CustomDomainsConfig{ - CNameBaseDomain: opts.CodesphereCustomDomainBaseDomain, - }, - DNSServers: opts.CodesphereDNSServers, - 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: opts.CodesphereWorkspaceImageBomRef, - }, - Pool: map[int]int{1: 1}, - }, - }, - }, - }, - }, - Plans: files.PlansConfig{ - HostingPlans: map[int]files.HostingPlan{ - 1: { - CPUTenth: opts.CodesphereHostingPlanCPU, - GPUParts: 0, - MemoryMb: opts.CodesphereHostingPlanMemory, - StorageMb: opts.CodesphereHostingPlanStorage, - TempStorageMb: opts.CodesphereHostingPlanTempStorage, - }, - }, - WorkspacePlans: map[int]files.WorkspacePlan{ - 1: { - Name: opts.CodesphereWorkspacePlanName, - HostingPlanID: 1, - MaxReplicas: opts.CodesphereWorkspacePlanMaxReplica, - OnDemand: true, - }, - }, - }, - } - - config.ManagedServiceBackends = &files.ManagedServiceBackendsConfig{ - Postgres: make(map[string]interface{}), - } - - return config, nil -} - -func (g *InstallConfig) collectAllConfigs(prompter *Prompter, opts *files.ConfigOptions, collected *files.CollectedConfig) { - 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) -} - -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 -} +// TODO: check for needed params in ApplyProfile, then delete +// func (g *InstallConfig) convertConfig() (*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 AddConfigComments(yamlData []byte) []byte { header := `# Codesphere Installer Configuration @@ -515,39 +330,39 @@ func AddVaultComments(yamlData []byte) []byte { return append([]byte(header), yamlData...) } -func ValidateConfig(config *files.RootConfig) []string { +func (g *InstallConfig) ValidateConfig() []string { errors := []string{} - if config.DataCenter.ID == 0 { + if g.Config.Datacenter.ID == 0 { errors = append(errors, "datacenter ID is required") } - if config.DataCenter.Name == "" { + if g.Config.Datacenter.Name == "" { errors = append(errors, "datacenter name is required") } - if len(config.Ceph.Hosts) == 0 { + if len(g.Config.Ceph.Hosts) == 0 { errors = append(errors, "at least one Ceph host is required") } - for _, host := range config.Ceph.Hosts { + for _, host := range g.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 { + if g.Config.Kubernetes.ManagedByCodesphere { + if len(g.Config.Kubernetes.ControlPlanes) == 0 { errors = append(errors, "at least one K8s control plane node is required") } } else { - if config.Kubernetes.PodCIDR == "" { + if g.Config.Kubernetes.PodCIDR == "" { errors = append(errors, "pod CIDR is required for external Kubernetes") } - if config.Kubernetes.ServiceCIDR == "" { + if g.Config.Kubernetes.ServiceCIDR == "" { errors = append(errors, "service CIDR is required for external Kubernetes") } } - if config.Codesphere.Domain == "" { + if g.Config.Codesphere.Domain == "" { errors = append(errors, "Codesphere domain is required") } diff --git a/internal/installer/config_manager_profile.go b/internal/installer/config_manager_profile.go new file mode 100644 index 00000000..16237c25 --- /dev/null +++ b/internal/installer/config_manager_profile.go @@ -0,0 +1,188 @@ +package installer + +import ( + "fmt" + + "github.com/codesphere-cloud/oms/internal/installer/files" +) + +const ( + PROFILE_DEV = "dev" + PROFILE_DEVELOPMENT = "development" + PROFILE_PROD = "prod" + PROFILE_PRODUCTION = "production" + PROFILE_MINIMAL = "minimal" +) + +func (g *InstallConfig) ApplyProfile(profile string) error { + if g.Config == nil { + g.Config = &files.RootConfig{} + } + + g.Config.Ceph.OSDs = []files.CephOSD{ + { + SpecID: "default", + Placement: files.CephPlacement{ + HostPattern: "*", + }, + DataDevices: files.CephDataDevices{ + Size: "240G:300G", + Limit: 1, + }, + DBDevices: files.CephDBDevices{ + Size: "120G:150G", + Limit: 1, + }, + }, + } + + // TODO: remove triplets + + switch profile { + case PROFILE_DEV, PROFILE_DEVELOPMENT: + g.Config.Datacenter.ID = 1 + g.Config.Datacenter.Name = "dev" + g.Config.Datacenter.City = "Karlsruhe" + g.Config.Datacenter.CountryCode = "DE" + // TODO: g.config.Postgres.Mode = "install" + g.Config.Postgres.Primary.IP = "127.0.0.1" + g.Config.Postgres.Primary.Hostname = "localhost" + g.Config.Ceph.NodesSubnet = "127.0.0.1/32" + g.Config.Ceph.Hosts = []files.CephHost{{Hostname: "localhost", IPAddress: "127.0.0.1", IsMaster: true}} + g.Config.Kubernetes.ManagedByCodesphere = true + g.Config.Kubernetes.APIServerHost = "127.0.0.1" + g.Config.Kubernetes.ControlPlanes = []files.K8sNode{{IPAddress: "127.0.0.1"}} + g.Config.Kubernetes.Workers = []files.K8sNode{{IPAddress: "127.0.0.1"}} + g.Config.Cluster.Gateway = files.GatewayConfig{ServiceType: "LoadBalancer"} + g.Config.Cluster.PublicGateway = files.GatewayConfig{ServiceType: "LoadBalancer"} + g.Config.Codesphere.Domain = "codesphere.local" + g.Config.Codesphere.WorkspaceHostingBaseDomain = "ws.local" + g.Config.Codesphere.CustomDomains.CNameBaseDomain = "custom.local" + g.Config.Codesphere.DNSServers = []string{"8.8.8.8", "1.1.1.1"} + g.Config.Codesphere.WorkspaceImages.Agent.BomRef = "workspace-agent-24.04" + g.Config.Codesphere.Plans = files.PlansConfig{ + HostingPlans: map[int]files.HostingPlan{ + 1: { + CPUTenth: 10, + GPUParts: 0, + MemoryMb: 2048, + StorageMb: 20480, + TempStorageMb: 1024, + }, + }, + WorkspacePlans: map[int]files.WorkspacePlan{ + 1: { + Name: "Standard", + HostingPlanID: 1, + MaxReplicas: 3, + OnDemand: true, + }, + }, + } + g.Config.Secrets.BaseDir = "/root/secrets" + fmt.Println("Applied 'dev' profile: single-node development setup") + + case PROFILE_PROD, PROFILE_PRODUCTION: + g.Config.Datacenter.ID = 1 + g.Config.Datacenter.Name = "production" + g.Config.Datacenter.City = "Karlsruhe" + g.Config.Datacenter.CountryCode = "DE" + // TODO: g.config.Postgres.Mode = "install" + g.Config.Postgres.Primary.IP = "10.50.0.2" + g.Config.Postgres.Primary.Hostname = "pg-primary" + g.Config.Postgres.Replica.IP = "10.50.0.3" + g.Config.Postgres.Replica.Name = "replica1" + g.Config.Ceph.NodesSubnet = "10.53.101.0/24" + g.Config.Ceph.Hosts = []files.CephHost{ + {Hostname: "ceph-node-0", IPAddress: "10.53.101.2", IsMaster: true}, + {Hostname: "ceph-node-1", IPAddress: "10.53.101.3", IsMaster: false}, + {Hostname: "ceph-node-2", IPAddress: "10.53.101.4", IsMaster: false}, + } + g.Config.Kubernetes.ManagedByCodesphere = true + g.Config.Kubernetes.APIServerHost = "10.50.0.2" + g.Config.Kubernetes.ControlPlanes = []files.K8sNode{ + {IPAddress: "10.50.0.2"}, + } + g.Config.Kubernetes.Workers = []files.K8sNode{ + {IPAddress: "10.50.0.2"}, + {IPAddress: "10.50.0.3"}, + {IPAddress: "10.50.0.4"}, + } + g.Config.Cluster.Gateway = files.GatewayConfig{ServiceType: "LoadBalancer"} + g.Config.Cluster.PublicGateway = files.GatewayConfig{ServiceType: "LoadBalancer"} + g.Config.Codesphere.Domain = "codesphere.yourcompany.com" + g.Config.Codesphere.WorkspaceHostingBaseDomain = "ws.yourcompany.com" + g.Config.Codesphere.CustomDomains.CNameBaseDomain = "custom.yourcompany.com" + g.Config.Codesphere.DNSServers = []string{"1.1.1.1", "8.8.8.8"} + g.Config.Codesphere.WorkspaceImages.Agent.BomRef = "workspace-agent-24.04" + g.Config.Codesphere.Plans = files.PlansConfig{ + HostingPlans: map[int]files.HostingPlan{ + 1: { + CPUTenth: 10, + GPUParts: 0, + MemoryMb: 2048, + StorageMb: 20480, + TempStorageMb: 1024, + }, + }, + WorkspacePlans: map[int]files.WorkspacePlan{ + 1: { + Name: "Standard Developer", + HostingPlanID: 1, + MaxReplicas: 3, + OnDemand: true, + }, + }, + } + g.Config.Secrets.BaseDir = "/root/secrets" + fmt.Println("Applied 'production' profile: HA multi-node setup") + + case PROFILE_MINIMAL: + g.Config.Datacenter.ID = 1 + g.Config.Datacenter.Name = "minimal" + g.Config.Datacenter.City = "Karlsruhe" + g.Config.Datacenter.CountryCode = "DE" + // TODO: g.config.Postgres.Mode = "install" + g.Config.Postgres.Primary.IP = "127.0.0.1" + g.Config.Postgres.Primary.Hostname = "localhost" + g.Config.Ceph.NodesSubnet = "127.0.0.1/32" + g.Config.Ceph.Hosts = []files.CephHost{{Hostname: "localhost", IPAddress: "127.0.0.1", IsMaster: true}} + g.Config.Kubernetes.ManagedByCodesphere = true + g.Config.Kubernetes.APIServerHost = "127.0.0.1" + g.Config.Kubernetes.ControlPlanes = []files.K8sNode{{IPAddress: "127.0.0.1"}} + g.Config.Kubernetes.Workers = []files.K8sNode{} + g.Config.Cluster.Gateway = files.GatewayConfig{ServiceType: "LoadBalancer"} + g.Config.Cluster.PublicGateway = files.GatewayConfig{ServiceType: "LoadBalancer"} + g.Config.Codesphere.Domain = "codesphere.local" + g.Config.Codesphere.WorkspaceHostingBaseDomain = "ws.local" + g.Config.Codesphere.CustomDomains.CNameBaseDomain = "custom.local" + g.Config.Codesphere.DNSServers = []string{"8.8.8.8"} + g.Config.Codesphere.WorkspaceImages.Agent.BomRef = "workspace-agent-24.04" + g.Config.Codesphere.Plans = files.PlansConfig{ + HostingPlans: map[int]files.HostingPlan{ + 1: { + CPUTenth: 10, + GPUParts: 0, + MemoryMb: 2048, + StorageMb: 20480, + TempStorageMb: 1024, + }, + }, + WorkspacePlans: map[int]files.WorkspacePlan{ + 1: { + Name: "Standard Developer", + HostingPlanID: 1, + MaxReplicas: 1, + OnDemand: true, + }, + }, + } + g.Config.Secrets.BaseDir = "/root/secrets" + fmt.Println("Applied 'minimal' profile: minimal single-node setup") + + default: + return fmt.Errorf("unknown profile: %s, available profiles: dev, prod, minimal", profile) + } + + return nil +} diff --git a/internal/installer/files/config_yaml.go b/internal/installer/files/config_yaml.go index 09f53c09..3ce244cd 100644 --- a/internal/installer/files/config_yaml.go +++ b/internal/installer/files/config_yaml.go @@ -11,9 +11,29 @@ import ( "gopkg.in/yaml.v3" ) +// Vault +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"` +} + // RootConfig represents the relevant parts of the configuration file type RootConfig struct { - DataCenter DataCenterConfig `yaml:"dataCenter"` + Datacenter DatacenterConfig `yaml:"dataCenter"` Secrets SecretsConfig `yaml:"secrets"` Registry RegistryConfig `yaml:"registry,omitempty"` Postgres PostgresConfig `yaml:"postgres"` @@ -25,7 +45,7 @@ type RootConfig struct { ManagedServiceBackends *ManagedServiceBackendsConfig `yaml:"managedServiceBackends,omitempty"` } -type DataCenterConfig struct { +type DatacenterConfig struct { ID int `yaml:"id"` Name string `yaml:"name"` City string `yaml:"city"` @@ -345,77 +365,6 @@ type RemoteWriteConfig struct { 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 @@ -427,64 +376,7 @@ type MetalLBPool struct { 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 -} - +// TODO: remove duplicate marshal function func (c *RootConfig) ParseConfig(filePath string) error { configData, err := os.ReadFile(filePath) if err != nil { From 3f6eeedfc3c263f451e5133d9ee77094b263350e Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Wed, 12 Nov 2025 15:06:39 +0000 Subject: [PATCH 15/24] chore(docs): Auto-update docs and licenses Signed-off-by: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> --- internal/installer/config_manager_profile.go | 3 + internal/installer/mocks.go | 117 ++++++++++++++----- 2 files changed, 90 insertions(+), 30 deletions(-) diff --git a/internal/installer/config_manager_profile.go b/internal/installer/config_manager_profile.go index 16237c25..79002790 100644 --- a/internal/installer/config_manager_profile.go +++ b/internal/installer/config_manager_profile.go @@ -1,3 +1,6 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + package installer import ( diff --git a/internal/installer/mocks.go b/internal/installer/mocks.go index 71cf7454..bdec512e 100644 --- a/internal/installer/mocks.go +++ b/internal/installer/mocks.go @@ -118,6 +118,51 @@ func (_m *MockInstallConfigManager) EXPECT() *MockInstallConfigManager_Expecter return &MockInstallConfigManager_Expecter{mock: &_m.Mock} } +// ApplyProfile provides a mock function for the type MockInstallConfigManager +func (_mock *MockInstallConfigManager) ApplyProfile(profile string) error { + ret := _mock.Called(profile) + + if len(ret) == 0 { + panic("no return value specified for ApplyProfile") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string) error); ok { + r0 = returnFunc(profile) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockInstallConfigManager_ApplyProfile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ApplyProfile' +type MockInstallConfigManager_ApplyProfile_Call struct { + *mock.Call +} + +// ApplyProfile is a helper method to define mock.On call +// - profile +func (_e *MockInstallConfigManager_Expecter) ApplyProfile(profile interface{}) *MockInstallConfigManager_ApplyProfile_Call { + return &MockInstallConfigManager_ApplyProfile_Call{Call: _e.mock.On("ApplyProfile", profile)} +} + +func (_c *MockInstallConfigManager_ApplyProfile_Call) Run(run func(profile string)) *MockInstallConfigManager_ApplyProfile_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockInstallConfigManager_ApplyProfile_Call) Return(err error) *MockInstallConfigManager_ApplyProfile_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockInstallConfigManager_ApplyProfile_Call) RunAndReturn(run func(profile string) error) *MockInstallConfigManager_ApplyProfile_Call { + _c.Call.Return(run) + return _c +} + // CollectInteractively provides a mock function for the type MockInstallConfigManager func (_mock *MockInstallConfigManager) CollectInteractively() error { ret := _mock.Called() @@ -206,81 +251,93 @@ func (_c *MockInstallConfigManager_GenerateSecrets_Call) RunAndReturn(run func() return _c } -// SetConfig provides a mock function for the type MockInstallConfigManager -func (_mock *MockInstallConfigManager) SetConfig(config *files.RootConfig) { - _mock.Called(config) - return +// GetConfig provides a mock function for the type MockInstallConfigManager +func (_mock *MockInstallConfigManager) GetConfig() *files.RootConfig { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for GetConfig") + } + + var r0 *files.RootConfig + if returnFunc, ok := ret.Get(0).(func() *files.RootConfig); ok { + r0 = returnFunc() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*files.RootConfig) + } + } + return r0 } -// MockInstallConfigManager_SetConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetConfig' -type MockInstallConfigManager_SetConfig_Call struct { +// MockInstallConfigManager_GetConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetConfig' +type MockInstallConfigManager_GetConfig_Call struct { *mock.Call } -// SetConfig is a helper method to define mock.On call -// - config -func (_e *MockInstallConfigManager_Expecter) SetConfig(config interface{}) *MockInstallConfigManager_SetConfig_Call { - return &MockInstallConfigManager_SetConfig_Call{Call: _e.mock.On("SetConfig", config)} +// GetConfig is a helper method to define mock.On call +func (_e *MockInstallConfigManager_Expecter) GetConfig() *MockInstallConfigManager_GetConfig_Call { + return &MockInstallConfigManager_GetConfig_Call{Call: _e.mock.On("GetConfig")} } -func (_c *MockInstallConfigManager_SetConfig_Call) Run(run func(config *files.RootConfig)) *MockInstallConfigManager_SetConfig_Call { +func (_c *MockInstallConfigManager_GetConfig_Call) Run(run func()) *MockInstallConfigManager_GetConfig_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(*files.RootConfig)) + run() }) return _c } -func (_c *MockInstallConfigManager_SetConfig_Call) Return() *MockInstallConfigManager_SetConfig_Call { - _c.Call.Return() +func (_c *MockInstallConfigManager_GetConfig_Call) Return(rootConfig *files.RootConfig) *MockInstallConfigManager_GetConfig_Call { + _c.Call.Return(rootConfig) return _c } -func (_c *MockInstallConfigManager_SetConfig_Call) RunAndReturn(run func(config *files.RootConfig)) *MockInstallConfigManager_SetConfig_Call { - _c.Run(run) +func (_c *MockInstallConfigManager_GetConfig_Call) RunAndReturn(run func() *files.RootConfig) *MockInstallConfigManager_GetConfig_Call { + _c.Call.Return(run) return _c } -// SetProfileValues provides a mock function for the type MockInstallConfigManager -func (_mock *MockInstallConfigManager) SetProfileValues(profile string) error { - ret := _mock.Called(profile) +// LoadConfigFromFile provides a mock function for the type MockInstallConfigManager +func (_mock *MockInstallConfigManager) LoadConfigFromFile(configPath string) error { + ret := _mock.Called(configPath) if len(ret) == 0 { - panic("no return value specified for SetProfileValues") + panic("no return value specified for LoadConfigFromFile") } var r0 error if returnFunc, ok := ret.Get(0).(func(string) error); ok { - r0 = returnFunc(profile) + r0 = returnFunc(configPath) } else { r0 = ret.Error(0) } return r0 } -// MockInstallConfigManager_SetProfileValues_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetProfileValues' -type MockInstallConfigManager_SetProfileValues_Call struct { +// MockInstallConfigManager_LoadConfigFromFile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LoadConfigFromFile' +type MockInstallConfigManager_LoadConfigFromFile_Call struct { *mock.Call } -// SetProfileValues is a helper method to define mock.On call -// - profile -func (_e *MockInstallConfigManager_Expecter) SetProfileValues(profile interface{}) *MockInstallConfigManager_SetProfileValues_Call { - return &MockInstallConfigManager_SetProfileValues_Call{Call: _e.mock.On("SetProfileValues", profile)} +// LoadConfigFromFile is a helper method to define mock.On call +// - configPath +func (_e *MockInstallConfigManager_Expecter) LoadConfigFromFile(configPath interface{}) *MockInstallConfigManager_LoadConfigFromFile_Call { + return &MockInstallConfigManager_LoadConfigFromFile_Call{Call: _e.mock.On("LoadConfigFromFile", configPath)} } -func (_c *MockInstallConfigManager_SetProfileValues_Call) Run(run func(profile string)) *MockInstallConfigManager_SetProfileValues_Call { +func (_c *MockInstallConfigManager_LoadConfigFromFile_Call) Run(run func(configPath string)) *MockInstallConfigManager_LoadConfigFromFile_Call { _c.Call.Run(func(args mock.Arguments) { run(args[0].(string)) }) return _c } -func (_c *MockInstallConfigManager_SetProfileValues_Call) Return(err error) *MockInstallConfigManager_SetProfileValues_Call { +func (_c *MockInstallConfigManager_LoadConfigFromFile_Call) Return(err error) *MockInstallConfigManager_LoadConfigFromFile_Call { _c.Call.Return(err) return _c } -func (_c *MockInstallConfigManager_SetProfileValues_Call) RunAndReturn(run func(profile string) error) *MockInstallConfigManager_SetProfileValues_Call { +func (_c *MockInstallConfigManager_LoadConfigFromFile_Call) RunAndReturn(run func(configPath string) error) *MockInstallConfigManager_LoadConfigFromFile_Call { _c.Call.Return(run) return _c } From f31da46d6f2985b836aecb60d77503995fa5f333 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Fri, 14 Nov 2025 09:20:48 +0100 Subject: [PATCH 16/24] ref: install config process --- cli/cmd/init_install_config.go | 329 +++++++++--------- .../init_install_config_interactive_test.go | 154 ++++++++ cli/cmd/init_install_config_test.go | 41 ++- internal/installer/config.go | 14 +- .../installer/config_generator_collector.go | 70 +++- internal/installer/config_manager.go | 214 ++---------- internal/installer/config_manager_profile.go | 153 ++++---- internal/installer/files/config_yaml.go | 29 +- internal/installer/files/config_yaml_test.go | 39 ++- 9 files changed, 555 insertions(+), 488 deletions(-) create mode 100644 cli/cmd/init_install_config_interactive_test.go diff --git a/cli/cmd/init_install_config.go b/cli/cmd/init_install_config.go index 6f57b28e..2de27a45 100644 --- a/cli/cmd/init_install_config.go +++ b/cli/cmd/init_install_config.go @@ -39,48 +39,47 @@ type InitInstallConfigOpts struct { 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 + RegistryServer string + RegistryReplaceImagesInBom bool + RegistryLoadContainerImages bool + + PostgresMode string + PostgresPrimaryIP string + PostgresPrimaryHostname string + PostgresReplicaIP string + PostgresReplicaName string + PostgresServerAddress string + + CephNodesSubnet string + CephHosts []files.CephHostConfig + + KubernetesManagedByCodesphere bool + KubernetesAPIServerHost string + KubernetesControlPlanes []string + KubernetesWorkers []string + KubernetesPodCIDR string + KubernetesServiceCIDR string + + ClusterGatewayServiceType string + ClusterGatewayIPAddresses []string + ClusterPublicGatewayServiceType string + ClusterPublicGatewayIPAddresses []string MetalLBEnabled bool MetalLBPools []files.MetalLBPool - CodesphereDomain string - CodespherePublicIP string - CodesphereWorkspaceBaseDomain string - CodesphereCustomDomainBaseDomain string - CodesphereDNSServers []string - CodesphereWorkspaceImageBomRef string - CodesphereHostingPlanCPU int - CodesphereHostingPlanMemory int - CodesphereHostingPlanStorage int - CodesphereHostingPlanTempStorage int - CodesphereWorkspacePlanName string - CodesphereWorkspacePlanMaxReplica int + CodesphereDomain string + CodespherePublicIP string + CodesphereWorkspaceHostingBaseDomain string + CodesphereCustomDomainsCNameBaseDomain string + CodesphereDNSServers []string + CodesphereWorkspaceImageBomRef string + CodesphereHostingPlanCPUTenth int + CodesphereHostingPlanMemoryMb int + CodesphereHostingPlanStorageMb int + CodesphereHostingPlanTempStorageMb int + CodesphereWorkspacePlanName string + CodesphereWorkspacePlanMaxReplicas int } func (c *InitInstallConfigCmd) RunE(_ *cobra.Command, args []string) error { @@ -134,8 +133,8 @@ func AddInitInstallConfigCmd(init *cobra.Command, opts *GlobalOptions) { 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().BoolVar(&c.Opts.KubernetesManagedByCodesphere, "k8s-managed", true, "Use Codesphere-managed Kubernetes") + c.cmd.Flags().StringSliceVar(&c.Opts.KubernetesControlPlanes, "k8s-control-plane", []string{}, "K8s control plane IPs (comma-separated)") c.cmd.Flags().StringVar(&c.Opts.CodesphereDomain, "domain", "", "Main Codesphere domain") @@ -147,30 +146,8 @@ func AddInitInstallConfigCmd(init *cobra.Command, opts *GlobalOptions) { } func (c *InitInstallConfigCmd) InitInstallConfig(icg installer.InstallConfigManager) error { - // Validation only mode if c.Opts.ValidateOnly { - // TODO: put into validateOnly method - err := icg.LoadConfigFromFile(c.Opts.ConfigFile) - if err != nil { - return fmt.Errorf("failed to load config file: %w", err) - } - - err = icg.Validate() - if err != nil { - return fmt.Errorf("configuration validation failed: %w", err) - } - - // err = icg.LoadVaultFromFile(c.Opts.VaultFile) - // if err != nil { - // return fmt.Errorf("failed to load vault file: %w", err) - // } - - // err = icg.ValidateVault() - // if err != nil { - // return fmt.Errorf("vault validation failed: %w", err) - // } - - return nil + return c.validateOnly(icg) } // Generate new configuration from either Opts or interactively @@ -239,18 +216,15 @@ func (c *InitInstallConfigCmd) printSuccessMessage() { func (c *InitInstallConfigCmd) validateOnly(icg installer.InstallConfigManager) error { fmt.Printf("Validating configuration files...\n") - // TODO: Check if config file can be empty - if c.Opts.ConfigFile != "" { - fmt.Printf("Reading config file: %s\n", c.Opts.ConfigFile) - err := icg.LoadConfigFromFile(c.Opts.ConfigFile) - if err != nil { - return fmt.Errorf("failed to load config file: %w", err) - } + fmt.Printf("Reading config file: %s\n", c.Opts.ConfigFile) + err := icg.LoadConfigFromFile(c.Opts.ConfigFile) + if err != nil { + return fmt.Errorf("failed to load config file: %w", err) + } - err = icg.Validate() - if err != nil { - return fmt.Errorf("configuration validation failed: %w", err) - } + err = icg.Validate() + if err != nil { + return fmt.Errorf("configuration validation failed: %w", err) } var errors []string @@ -266,8 +240,8 @@ func (c *InitInstallConfigCmd) validateOnly(icg installer.InstallConfigManager) if err != nil { errors = append(errors, fmt.Sprintf("failed to read vault.yaml: %v", err)) } else { - vault, err := installer.UnmarshalVault(vaultData) - if err != nil { + vault := &files.InstallVault{} + if err := vault.Unmarshal(vaultData); err != nil { errors = append(errors, fmt.Sprintf("failed to parse vault.yaml: %v", err)) } else { vaultErrors := installer.ValidateVault(vault) @@ -291,94 +265,115 @@ func (c *InitInstallConfigCmd) validateOnly(icg installer.InstallConfigManager) func (c *InitInstallConfigCmd) updateConfigFromOpts(config *files.RootConfig) *files.RootConfig { // Datacenter settings - config.Datacenter.ID = c.Opts.DatacenterID - config.Datacenter.City = c.Opts.DatacenterCity - config.Datacenter.CountryCode = c.Opts.DatacenterCountryCode - config.Datacenter.Name = c.Opts.DatacenterName + if c.Opts.DatacenterID != 0 { + config.Datacenter.ID = c.Opts.DatacenterID + } + if c.Opts.DatacenterCity != "" { + config.Datacenter.City = c.Opts.DatacenterCity + } + if c.Opts.DatacenterCountryCode != "" { + config.Datacenter.CountryCode = c.Opts.DatacenterCountryCode + } + if c.Opts.DatacenterName != "" { + config.Datacenter.Name = c.Opts.DatacenterName + } // Registry settings - config.Registry.LoadContainerImages = c.Opts.RegistryLoadContainerImgs - config.Registry.ReplaceImagesInBom = c.Opts.RegistryReplaceImages - config.Registry.Server = c.Opts.RegistryServer + if c.Opts.RegistryServer != "" { + config.Registry.LoadContainerImages = c.Opts.RegistryLoadContainerImages + config.Registry.ReplaceImagesInBom = c.Opts.RegistryReplaceImagesInBom + config.Registry.Server = c.Opts.RegistryServer + } // Postgres settings - if c.Opts.PostgresExternal != "" { - config.Postgres.ServerAddress = c.Opts.PostgresExternal + if c.Opts.PostgresMode != "" { + config.Postgres.Mode = c.Opts.PostgresMode + } + + if c.Opts.PostgresServerAddress != "" { + config.Postgres.ServerAddress = c.Opts.PostgresServerAddress } - if c.Opts.PostgresPrimaryHost != "" && c.Opts.PostgresPrimaryIP != "" { + if c.Opts.PostgresPrimaryHostname != "" && c.Opts.PostgresPrimaryIP != "" { if config.Postgres.Primary == nil { - // TODO: Mode: c.Opts.PostgresMode, - // TODO: External: c.Opts.PostgresExternal, config.Postgres.Primary = &files.PostgresPrimaryConfig{ - Hostname: c.Opts.PostgresPrimaryHost, + Hostname: c.Opts.PostgresPrimaryHostname, IP: c.Opts.PostgresPrimaryIP, } } else { - // TODO: Mode: c.Opts.PostgresMode, - // TODO: External: c.Opts.PostgresExternal, - config.Postgres.Primary.Hostname = c.Opts.PostgresPrimaryHost + config.Postgres.Primary.Hostname = c.Opts.PostgresPrimaryHostname config.Postgres.Primary.IP = c.Opts.PostgresPrimaryIP } } if c.Opts.PostgresReplicaIP != "" && c.Opts.PostgresReplicaName != "" { if config.Postgres.Replica == nil { - // TODO: Mode: c.Opts.PostgresMode, - // TODO: External: c.Opts.PostgresExternal, config.Postgres.Replica = &files.PostgresReplicaConfig{ Name: c.Opts.PostgresReplicaName, IP: c.Opts.PostgresReplicaIP, } } else { - // TODO: Mode: c.Opts.PostgresMode, - // TODO: External: c.Opts.PostgresExternal, config.Postgres.Replica.Name = c.Opts.PostgresReplicaName config.Postgres.Replica.IP = c.Opts.PostgresReplicaIP } } // Ceph settings - config.Ceph.NodesSubnet = c.Opts.CephSubnet - cephHosts := []files.CephHost{} - for _, hostCfg := range c.Opts.CephHosts { - cephHosts = append(config.Ceph.Hosts, files.CephHost{ - Hostname: hostCfg.Hostname, - IPAddress: hostCfg.IPAddress, - IsMaster: hostCfg.IsMaster, - }) - } - if len(cephHosts) > 0 { + if c.Opts.CephNodesSubnet != "" { + config.Ceph.NodesSubnet = c.Opts.CephNodesSubnet + } + if len(c.Opts.CephHosts) > 0 { + cephHosts := []files.CephHost{} + for _, hostCfg := range c.Opts.CephHosts { + cephHosts = append(cephHosts, files.CephHost(hostCfg)) + } config.Ceph.Hosts = cephHosts } // Kubernetes settings - config.Kubernetes.ManagedByCodesphere = c.Opts.K8sManaged - config.Kubernetes.APIServerHost = c.Opts.K8sAPIServer - config.Kubernetes.PodCIDR = c.Opts.K8sPodCIDR - config.Kubernetes.ServiceCIDR = c.Opts.K8sServiceCIDR + if c.Opts.KubernetesAPIServerHost != "" { + config.Kubernetes.APIServerHost = c.Opts.KubernetesAPIServerHost + } + if c.Opts.KubernetesPodCIDR != "" { + config.Kubernetes.PodCIDR = c.Opts.KubernetesPodCIDR + } + if c.Opts.KubernetesServiceCIDR != "" { + config.Kubernetes.ServiceCIDR = c.Opts.KubernetesServiceCIDR + } - kubernetesControlPlanes := []files.K8sNode{} - for _, ip := range c.Opts.K8sControlPlane { - kubernetesControlPlanes = append(kubernetesControlPlanes, files.K8sNode{ - IPAddress: ip, - }) + if len(c.Opts.KubernetesControlPlanes) > 0 { + kubernetesControlPlanes := []files.K8sNode{} + for _, ip := range c.Opts.KubernetesControlPlanes { + kubernetesControlPlanes = append(kubernetesControlPlanes, files.K8sNode{ + IPAddress: ip, + }) + } + config.Kubernetes.ControlPlanes = kubernetesControlPlanes } - config.Kubernetes.ControlPlanes = kubernetesControlPlanes - kubernetesWorkers := []files.K8sNode{} - for _, ip := range c.Opts.K8sWorkers { - kubernetesWorkers = append(kubernetesWorkers, files.K8sNode{ - IPAddress: ip, - }) + if len(c.Opts.KubernetesWorkers) > 0 { + kubernetesWorkers := []files.K8sNode{} + for _, ip := range c.Opts.KubernetesWorkers { + kubernetesWorkers = append(kubernetesWorkers, files.K8sNode{ + IPAddress: ip, + }) + } + config.Kubernetes.Workers = kubernetesWorkers } - config.Kubernetes.Workers = kubernetesWorkers // Cluster Gateway settings - config.Cluster.Gateway.ServiceType = c.Opts.ClusterGatewayType - config.Cluster.Gateway.IPAddresses = c.Opts.ClusterGatewayIPs - config.Cluster.PublicGateway.ServiceType = c.Opts.ClusterPublicGatewayType - config.Cluster.PublicGateway.IPAddresses = c.Opts.ClusterPublicGatewayIPs + if c.Opts.ClusterGatewayServiceType != "" { + config.Cluster.Gateway.ServiceType = c.Opts.ClusterGatewayServiceType + } + if len(c.Opts.ClusterGatewayIPAddresses) > 0 { + config.Cluster.Gateway.IPAddresses = c.Opts.ClusterGatewayIPAddresses + } + if c.Opts.ClusterPublicGatewayServiceType != "" { + config.Cluster.PublicGateway.ServiceType = c.Opts.ClusterPublicGatewayServiceType + } + if len(c.Opts.ClusterPublicGatewayIPAddresses) > 0 { + config.Cluster.PublicGateway.IPAddresses = c.Opts.ClusterPublicGatewayIPAddresses + } // MetalLB settings if c.Opts.MetalLBEnabled { @@ -393,49 +388,63 @@ func (c *InitInstallConfigCmd) updateConfigFromOpts(config *files.RootConfig) *f } for _, pool := range c.Opts.MetalLBPools { - config.MetalLB.Pools = append(config.MetalLB.Pools, files.MetalLBPoolDef{ - Name: pool.Name, - IPAddresses: pool.IPAddresses, - // TODO: ARPEnabled: pool.ARPEnabled, - }) + config.MetalLB.Pools = append(config.MetalLB.Pools, files.MetalLBPoolDef(pool)) } } // Codesphere settings - config.Codesphere.Domain = c.Opts.CodesphereDomain - config.Codesphere.PublicIP = c.Opts.CodespherePublicIP - config.Codesphere.WorkspaceHostingBaseDomain = c.Opts.CodesphereWorkspaceBaseDomain - config.Codesphere.CustomDomains = files.CustomDomainsConfig{CNameBaseDomain: c.Opts.CodesphereCustomDomainBaseDomain} - config.Codesphere.DNSServers = c.Opts.CodesphereDNSServers - - if config.Codesphere.WorkspaceImages == nil { - config.Codesphere.WorkspaceImages = &files.WorkspaceImagesConfig{} - } - config.Codesphere.WorkspaceImages.Agent = &files.ImageRef{ - BomRef: c.Opts.CodesphereWorkspaceImageBomRef, - } - - config.Codesphere.Plans = files.PlansConfig{ - HostingPlans: map[int]files.HostingPlan{ - 1: { - CPUTenth: c.Opts.CodesphereHostingPlanCPU, - MemoryMb: c.Opts.CodesphereHostingPlanMemory, - StorageMb: c.Opts.CodesphereHostingPlanStorage, - TempStorageMb: c.Opts.CodesphereHostingPlanTempStorage, + if c.Opts.CodesphereDomain != "" { + config.Codesphere.Domain = c.Opts.CodesphereDomain + } + if c.Opts.CodespherePublicIP != "" { + config.Codesphere.PublicIP = c.Opts.CodespherePublicIP + } + if c.Opts.CodesphereWorkspaceHostingBaseDomain != "" { + config.Codesphere.WorkspaceHostingBaseDomain = c.Opts.CodesphereWorkspaceHostingBaseDomain + } + if c.Opts.CodesphereCustomDomainsCNameBaseDomain != "" { + config.Codesphere.CustomDomains = files.CustomDomainsConfig{CNameBaseDomain: c.Opts.CodesphereCustomDomainsCNameBaseDomain} + } + if len(c.Opts.CodesphereDNSServers) > 0 { + config.Codesphere.DNSServers = c.Opts.CodesphereDNSServers + } + + if c.Opts.CodesphereWorkspaceImageBomRef != "" { + if config.Codesphere.WorkspaceImages == nil { + config.Codesphere.WorkspaceImages = &files.WorkspaceImagesConfig{} + } + config.Codesphere.WorkspaceImages.Agent = &files.ImageRef{ + BomRef: c.Opts.CodesphereWorkspaceImageBomRef, + } + } + + // Plans + if c.Opts.CodesphereHostingPlanCPUTenth != 0 || c.Opts.CodesphereHostingPlanMemoryMb != 0 || + c.Opts.CodesphereHostingPlanStorageMb != 0 || c.Opts.CodesphereHostingPlanTempStorageMb != 0 { + config.Codesphere.Plans = files.PlansConfig{ + HostingPlans: map[int]files.HostingPlan{ + 1: { + CPUTenth: c.Opts.CodesphereHostingPlanCPUTenth, + MemoryMb: c.Opts.CodesphereHostingPlanMemoryMb, + StorageMb: c.Opts.CodesphereHostingPlanStorageMb, + TempStorageMb: c.Opts.CodesphereHostingPlanTempStorageMb, + }, }, - }, - WorkspacePlans: map[int]files.WorkspacePlan{ - 1: { - Name: c.Opts.CodesphereWorkspacePlanName, - HostingPlanID: 1, - MaxReplicas: c.Opts.CodesphereWorkspacePlanMaxReplica, - OnDemand: true, + WorkspacePlans: map[int]files.WorkspacePlan{ + 1: { + Name: c.Opts.CodesphereWorkspacePlanName, + HostingPlanID: 1, + MaxReplicas: c.Opts.CodesphereWorkspacePlanMaxReplicas, + OnDemand: true, + }, }, - }, + } } // Secrets base dir - config.Secrets.BaseDir = c.Opts.SecretsBaseDir + if c.Opts.SecretsBaseDir != "" && c.Opts.SecretsBaseDir != "/root/secrets" { + config.Secrets.BaseDir = c.Opts.SecretsBaseDir + } return config } diff --git a/cli/cmd/init_install_config_interactive_test.go b/cli/cmd/init_install_config_interactive_test.go new file mode 100644 index 00000000..6631047c --- /dev/null +++ b/cli/cmd/init_install_config_interactive_test.go @@ -0,0 +1,154 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "os" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/codesphere-cloud/oms/internal/installer" + "github.com/codesphere-cloud/oms/internal/util" +) + +var _ = Describe("Interactive profile usage", func() { + Context("when using profile with interactive mode", func() { + It("should use profile values as defaults", func() { + icg := installer.NewInstallConfigManager() + + // Apply dev profile first (like the command does) + err := icg.ApplyProfile("dev") + Expect(err).NotTo(HaveOccurred()) + + config := icg.GetConfig() + + // Verify that profile values are set correctly + Expect(config.Datacenter.ID).To(Equal(1)) + Expect(config.Datacenter.Name).To(Equal("dev")) + Expect(config.Datacenter.City).To(Equal("Karlsruhe")) + Expect(config.Datacenter.CountryCode).To(Equal("DE")) + + // Postgres should be set to install mode with localhost + Expect(config.Postgres.Mode).To(Equal("install")) + Expect(config.Postgres.Primary).NotTo(BeNil()) + Expect(config.Postgres.Primary.IP).To(Equal("127.0.0.1")) + Expect(config.Postgres.Primary.Hostname).To(Equal("localhost")) + + // Ceph should be configured for localhost + Expect(config.Ceph.NodesSubnet).To(Equal("127.0.0.1/32")) + Expect(config.Ceph.Hosts).To(HaveLen(1)) + Expect(config.Ceph.Hosts[0].Hostname).To(Equal("localhost")) + Expect(config.Ceph.Hosts[0].IPAddress).To(Equal("127.0.0.1")) + Expect(config.Ceph.Hosts[0].IsMaster).To(BeTrue()) + + // Kubernetes should be managed and use localhost + Expect(config.Kubernetes.ManagedByCodesphere).To(BeTrue()) + Expect(config.Kubernetes.APIServerHost).To(Equal("127.0.0.1")) + Expect(config.Kubernetes.ControlPlanes).To(HaveLen(1)) + Expect(config.Kubernetes.ControlPlanes[0].IPAddress).To(Equal("127.0.0.1")) + Expect(config.Kubernetes.Workers).To(HaveLen(1)) + Expect(config.Kubernetes.Workers[0].IPAddress).To(Equal("127.0.0.1")) + + // Codesphere domain should be set + Expect(config.Codesphere.Domain).To(Equal("codesphere.local")) + Expect(config.Codesphere.WorkspaceHostingBaseDomain).To(Equal("ws.local")) + Expect(config.Codesphere.CustomDomains.CNameBaseDomain).To(Equal("custom.local")) + }) + + It("should allow non-interactive collection to use profile defaults", func() { + icg := installer.NewInstallConfigManager() + + // Apply dev profile + err := icg.ApplyProfile("dev") + Expect(err).NotTo(HaveOccurred()) + + // In non-interactive mode, CollectInteractively would use defaults + // We simulate this by checking that the prompter returns defaults + // when interactive=false + prompter := installer.NewPrompter(false) + + // Test that prompter returns defaults when not interactive + Expect(prompter.String("Test", "default-value")).To(Equal("default-value")) + Expect(prompter.Int("Test", 42)).To(Equal(42)) + Expect(prompter.Bool("Test", true)).To(BeTrue()) + Expect(prompter.Choice("Test", []string{"a", "b"}, "a")).To(Equal("a")) + Expect(prompter.StringSlice("Test", []string{"1", "2"})).To(Equal([]string{"1", "2"})) + }) + + It("should generate valid config files with profile", func() { + configFile, err := os.CreateTemp("", "config-*.yaml") + Expect(err).NotTo(HaveOccurred()) + defer os.Remove(configFile.Name()) + configFile.Close() + + vaultFile, err := os.CreateTemp("", "vault-*.yaml") + Expect(err).NotTo(HaveOccurred()) + defer os.Remove(vaultFile.Name()) + vaultFile.Close() + + c := &InitInstallConfigCmd{ + Opts: &InitInstallConfigOpts{ + GlobalOptions: &GlobalOptions{}, + ConfigFile: configFile.Name(), + VaultFile: vaultFile.Name(), + Profile: "dev", + Interactive: false, // Non-interactive to avoid stdin issues + }, + FileWriter: util.NewFilesystemWriter(), + } + + icg := installer.NewInstallConfigManager() + err = c.InitInstallConfig(icg) + Expect(err).NotTo(HaveOccurred()) + + // Verify files were created + _, err = os.Stat(configFile.Name()) + Expect(err).NotTo(HaveOccurred()) + + _, err = os.Stat(vaultFile.Name()) + Expect(err).NotTo(HaveOccurred()) + + // Verify config content + err = icg.LoadConfigFromFile(configFile.Name()) + Expect(err).NotTo(HaveOccurred()) + + config := icg.GetConfig() + Expect(config.Datacenter.Name).To(Equal("dev")) + Expect(config.Codesphere.Domain).To(Equal("codesphere.local")) + }) + }) + + Context("when using production profile", func() { + It("should set production-specific defaults", func() { + icg := installer.NewInstallConfigManager() + + err := icg.ApplyProfile("production") + Expect(err).NotTo(HaveOccurred()) + + config := icg.GetConfig() + + // Verify production-specific values + Expect(config.Datacenter.Name).To(Equal("production")) + Expect(config.Postgres.Primary.IP).To(Equal("10.50.0.2")) + Expect(config.Postgres.Replica).NotTo(BeNil()) + Expect(config.Postgres.Replica.IP).To(Equal("10.50.0.3")) + + // Ceph should have 3 nodes + Expect(config.Ceph.Hosts).To(HaveLen(3)) + Expect(config.Ceph.Hosts[0].Hostname).To(Equal("ceph-node-0")) + Expect(config.Ceph.Hosts[0].IPAddress).To(Equal("10.53.101.2")) + Expect(config.Ceph.Hosts[0].IsMaster).To(BeTrue()) + + Expect(config.Ceph.Hosts[1].Hostname).To(Equal("ceph-node-1")) + Expect(config.Ceph.Hosts[1].IsMaster).To(BeFalse()) + + // Kubernetes should have multiple workers + Expect(config.Kubernetes.ControlPlanes).To(HaveLen(1)) + Expect(config.Kubernetes.Workers).To(HaveLen(3)) + + Expect(config.Codesphere.Domain).To(Equal("codesphere.yourcompany.com")) + }) + }) +}) diff --git a/cli/cmd/init_install_config_test.go b/cli/cmd/init_install_config_test.go index bfc8cd63..ec72e2e3 100644 --- a/cli/cmd/init_install_config_test.go +++ b/cli/cmd/init_install_config_test.go @@ -9,24 +9,22 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/codesphere-cloud/oms/internal/installer" "github.com/codesphere-cloud/oms/internal/util" ) var _ = Describe("ApplyProfile", func() { DescribeTable("profile application", - func(profile string, wantErr bool, checkDatacenter string) { - c := &InitInstallConfigCmd{ - Opts: &InitInstallConfigOpts{ - Profile: profile, - }, - } + func(profile string, wantErr bool, checkDatacenterName string) { + icg := installer.NewInstallConfigManager() - err := c.applyProfile() + err := icg.ApplyProfile(profile) if wantErr { Expect(err).To(HaveOccurred()) } else { Expect(err).NotTo(HaveOccurred()) - Expect(c.Opts.DatacenterName).To(Equal(checkDatacenter)) + config := icg.GetConfig() + Expect(config.Datacenter.Name).To(Equal(checkDatacenterName)) } }, Entry("dev profile", "dev", false, "dev"), @@ -39,18 +37,15 @@ var _ = Describe("ApplyProfile", func() { Context("dev profile details", func() { It("sets correct dev profile configuration", func() { - c := &InitInstallConfigCmd{ - Opts: &InitInstallConfigOpts{ - Profile: "dev", - }, - } + icg := installer.NewInstallConfigManager() - err := c.applyProfile() + err := icg.ApplyProfile("dev") 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()) + config := icg.GetConfig() + Expect(config.Datacenter.ID).To(Equal(1)) + Expect(config.Datacenter.Name).To(Equal("dev")) + Expect(config.Postgres.Mode).To(Equal("install")) + Expect(config.Kubernetes.ManagedByCodesphere).To(BeTrue()) }) }) }) @@ -79,6 +74,7 @@ var _ = Describe("ValidateConfig", func() { secrets: baseDir: /root/secrets postgres: + mode: external serverAddress: postgres.example.com:5432 ceph: cephAdmSshKey: @@ -185,7 +181,8 @@ codesphere: FileWriter: util.NewFilesystemWriter(), } - err = c.validateConfig() + icg := installer.NewInstallConfigManager() + err = c.validateOnly(icg) Expect(err).NotTo(HaveOccurred()) }) }) @@ -235,7 +232,8 @@ codesphere: FileWriter: util.NewFilesystemWriter(), } - err = c.validateConfig() + icg := installer.NewInstallConfigManager() + err = c.validateOnly(icg) Expect(err).To(HaveOccurred()) }) }) @@ -296,7 +294,8 @@ codesphere: FileWriter: util.NewFilesystemWriter(), } - err = c.validateConfig() + icg := installer.NewInstallConfigManager() + err = c.validateOnly(icg) Expect(err).To(HaveOccurred()) }) }) diff --git a/internal/installer/config.go b/internal/installer/config.go index 45c96761..f7bcc14c 100644 --- a/internal/installer/config.go +++ b/internal/installer/config.go @@ -5,6 +5,7 @@ package installer import ( "fmt" + "io" "github.com/codesphere-cloud/oms/internal/installer/files" "github.com/codesphere-cloud/oms/internal/util" @@ -27,8 +28,19 @@ func NewConfig() *Config { // ParseConfigYaml reads and parses the configuration YAML file at the given path. func (c *Config) ParseConfigYaml(configPath string) (files.RootConfig, error) { var rootConfig files.RootConfig - err := rootConfig.ParseConfig(configPath) + + file, err := c.FileIO.Open(configPath) + if err != nil { + return rootConfig, fmt.Errorf("failed to open config file: %w", err) + } + defer util.CloseFileIgnoreError(file) + + data, err := io.ReadAll(file) if err != nil { + return rootConfig, fmt.Errorf("failed to read config file: %w", err) + } + + if err := rootConfig.Unmarshal(data); err != nil { return rootConfig, fmt.Errorf("failed to parse config.yaml: %w", err) } diff --git a/internal/installer/config_generator_collector.go b/internal/installer/config_generator_collector.go index 54a93c12..5181cb12 100644 --- a/internal/installer/config_generator_collector.go +++ b/internal/installer/config_generator_collector.go @@ -56,6 +56,22 @@ func (g *InstallConfig) collectChoice(prompter *Prompter, prompt string, options }) } +func k8sNodesToStringSlice(nodes []files.K8sNode) []string { + ips := make([]string, len(nodes)) + for i, node := range nodes { + ips[i] = node.IPAddress + } + return ips +} + +func stringSliceToK8sNodes(ips []string) []files.K8sNode { + nodes := make([]files.K8sNode, len(ips)) + for i, ip := range ips { + nodes[i] = files.K8sNode{IPAddress: ip} + } + return nodes +} + func (g *InstallConfig) collectDatacenterConfig(prompter *Prompter) { fmt.Println("=== Datacenter Configuration ===") g.Config.Datacenter.ID = g.collectInt(prompter, "Datacenter ID", g.Config.Datacenter.ID) @@ -76,20 +92,26 @@ func (g *InstallConfig) collectRegistryConfig(prompter *Prompter) { func (g *InstallConfig) collectPostgresConfig(prompter *Prompter) { fmt.Println("\n=== PostgreSQL Configuration ===") - // // TODO: create mode in generator - // g.config.Postgres.Mode = g.collectChoice(prompter, "PostgreSQL setup", []string{"install", "external"}, "install") - - // if g.config.Postgres.Mode == "install" { - // g.config.Postgres.Primary.IP = g.collectString(prompter, "Primary PostgreSQL server IP", "10.50.0.2") - // g.config.Postgres.Primary.Hostname = g.collectString(prompter, "Primary PostgreSQL hostname", "pg-primary-node") - // hasReplica := prompter.Bool("Configure PostgreSQL replica", g.config.Postgres.Replica != nil) - // if hasReplica { - // g.config.Postgres.Replica.IP = g.collectString(prompter, "Replica PostgreSQL server IP", "10.50.0.3") - // g.config.Postgres.Replica.Name = g.collectString(prompter, "Replica name (lowercase alphanumeric + underscore only)", "replica1") - // } - // } else { - // g.config.Postgres.ServerAddress = g.collectString(prompter, "External PostgreSQL server address", "postgres.example.com:5432") - // } + g.Config.Postgres.Mode = g.collectChoice(prompter, "PostgreSQL setup", []string{"install", "external"}, "install") + + if g.Config.Postgres.Mode == "install" { + if g.Config.Postgres.Primary == nil { + g.Config.Postgres.Primary = &files.PostgresPrimaryConfig{} + } + g.Config.Postgres.Primary.IP = g.collectString(prompter, "Primary PostgreSQL server IP", "10.50.0.2") + g.Config.Postgres.Primary.Hostname = g.collectString(prompter, "Primary PostgreSQL hostname", "pg-primary-node") + + hasReplica := prompter.Bool("Configure PostgreSQL replica", g.Config.Postgres.Replica != nil) + if hasReplica { + if g.Config.Postgres.Replica == nil { + g.Config.Postgres.Replica = &files.PostgresReplicaConfig{} + } + g.Config.Postgres.Replica.IP = g.collectString(prompter, "Replica PostgreSQL server IP", "10.50.0.3") + g.Config.Postgres.Replica.Name = g.collectString(prompter, "Replica name (lowercase alphanumeric + underscore only)", "replica1") + } + } else { + g.Config.Postgres.ServerAddress = g.collectString(prompter, "External PostgreSQL server address", "postgres.example.com:5432") + } } func (g *InstallConfig) collectCephConfig(prompter *Prompter) { @@ -119,12 +141,26 @@ func (g *InstallConfig) collectK8sConfig(prompter *Prompter) { if g.Config.Kubernetes.ManagedByCodesphere { g.Config.Kubernetes.APIServerHost = g.collectString(prompter, "Kubernetes API server host (LB/DNS/IP)", "10.50.0.2") - // TODO: convert existing config params to string array and after collection convert back - // g.config.Kubernetes.ControlPlanes = g.collectStringSlice(prompter, "Control plane IP addresses (comma-separated)", []string{"10.50.0.2"}) - // g.config.Kubernetes.Workers = g.collectStringSlice(prompter, "Worker node IP addresses (comma-separated)", []string{"10.50.0.2", "10.50.0.3", "10.50.0.4"}) + + defaultControlPlanes := k8sNodesToStringSlice(g.Config.Kubernetes.ControlPlanes) + if len(defaultControlPlanes) == 0 { + defaultControlPlanes = []string{"10.50.0.2"} + } + defaultWorkers := k8sNodesToStringSlice(g.Config.Kubernetes.Workers) + if len(defaultWorkers) == 0 { + defaultWorkers = []string{"10.50.0.2", "10.50.0.3", "10.50.0.4"} + } + + controlPlaneIPs := g.collectStringSlice(prompter, "Control plane IP addresses (comma-separated)", defaultControlPlanes) + workerIPs := g.collectStringSlice(prompter, "Worker node IP addresses (comma-separated)", defaultWorkers) + + g.Config.Kubernetes.ControlPlanes = stringSliceToK8sNodes(controlPlaneIPs) + g.Config.Kubernetes.Workers = stringSliceToK8sNodes(workerIPs) + g.Config.Kubernetes.NeedsKubeConfig = false } else { g.Config.Kubernetes.PodCIDR = g.collectString(prompter, "Pod CIDR of external cluster", "100.96.0.0/11") g.Config.Kubernetes.ServiceCIDR = g.collectString(prompter, "Service CIDR of external cluster", "100.64.0.0/13") + g.Config.Kubernetes.NeedsKubeConfig = true fmt.Println("Note: You'll need to provide kubeconfig in the vault file for external Kubernetes") } } diff --git a/internal/installer/config_manager.go b/internal/installer/config_manager.go index 2504b4f6..a69cb777 100644 --- a/internal/installer/config_manager.go +++ b/internal/installer/config_manager.go @@ -11,7 +11,6 @@ import ( "github.com/codesphere-cloud/oms/internal/installer/files" "github.com/codesphere-cloud/oms/internal/util" - "gopkg.in/yaml.v3" ) type InstallConfigManager interface { @@ -52,8 +51,8 @@ func (g *InstallConfig) LoadConfigFromFile(configPath string) error { return fmt.Errorf("failed to read %s: %w", configPath, err) } - config, err := UnmarshalConfig(data) - if err != nil { + config := &files.RootConfig{} + if err := config.Unmarshal(data); err != nil { return fmt.Errorf("failed to unmarshal %s: %w", configPath, err) } @@ -95,7 +94,7 @@ func (g *InstallConfig) WriteInstallConfig(configPath string, withComments bool) return fmt.Errorf("no configuration provided - config is nil") } - configYAML, err := MarshalConfig(g.Config) + configYAML, err := g.Config.Marshal() if err != nil { return fmt.Errorf("failed to marshal config.yaml: %w", err) } @@ -117,7 +116,7 @@ func (g *InstallConfig) WriteVault(vaultPath string, withComments bool) error { } vault := g.Config.ExtractVault() - vaultYAML, err := MarshalVault(vault) + vaultYAML, err := vault.Marshal() if err != nil { return fmt.Errorf("failed to marshal vault.yaml: %w", err) } @@ -133,167 +132,6 @@ func (g *InstallConfig) WriteVault(vaultPath string, withComments bool) error { return nil } -// TODO: check for needed params in ApplyProfile, then delete -// func (g *InstallConfig) convertConfig() (*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 AddConfigComments(yamlData []byte) []byte { header := `# Codesphere Installer Configuration # Generated by OMS CLI @@ -340,6 +178,30 @@ func (g *InstallConfig) ValidateConfig() []string { errors = append(errors, "datacenter name is required") } + if g.Config.Postgres.Mode == "" { + errors = append(errors, "postgres mode is required (install or external)") + } else if g.Config.Postgres.Mode != "install" && g.Config.Postgres.Mode != "external" { + errors = append(errors, fmt.Sprintf("invalid postgres mode: %s (must be 'install' or 'external')", g.Config.Postgres.Mode)) + } + + switch g.Config.Postgres.Mode { + case "install": + if g.Config.Postgres.Primary == nil { + errors = append(errors, "postgres primary configuration is required when mode is 'install'") + } else { + if g.Config.Postgres.Primary.IP == "" { + errors = append(errors, "postgres primary IP is required") + } + if g.Config.Postgres.Primary.Hostname == "" { + errors = append(errors, "postgres primary hostname is required") + } + } + case "external": + if g.Config.Postgres.ServerAddress == "" { + errors = append(errors, "postgres server address is required when mode is 'external'") + } + } + if len(g.Config.Ceph.Hosts) == 0 { errors = append(errors, "at least one Ceph host is required") } @@ -390,23 +252,3 @@ func ValidateVault(vault *files.InstallVault) []string { 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/config_manager_profile.go b/internal/installer/config_manager_profile.go index 79002790..9033d0bf 100644 --- a/internal/installer/config_manager_profile.go +++ b/internal/installer/config_manager_profile.go @@ -39,58 +39,86 @@ func (g *InstallConfig) ApplyProfile(profile string) error { }, } - // TODO: remove triplets + g.Config.Datacenter.ID = 1 + g.Config.Datacenter.City = "Karlsruhe" + g.Config.Datacenter.CountryCode = "DE" + g.Config.Postgres.Mode = "install" + g.Config.Postgres.Primary = &files.PostgresPrimaryConfig{} + g.Config.Postgres.Replica = &files.PostgresReplicaConfig{} + g.Config.Kubernetes.ManagedByCodesphere = true + g.Config.Kubernetes.NeedsKubeConfig = false + g.Config.Cluster.Certificates = files.ClusterCertificates{ + CA: files.CAConfig{ + Algorithm: "RSA", + KeySizeBits: 2048, + }, + } + g.Config.Cluster.Gateway = files.GatewayConfig{ServiceType: "LoadBalancer"} + g.Config.Cluster.PublicGateway = files.GatewayConfig{ServiceType: "LoadBalancer"} + g.Config.Codesphere.Experiments = []string{} + g.Config.Codesphere.WorkspaceImages = &files.WorkspaceImagesConfig{ + Agent: &files.ImageRef{ + BomRef: "workspace-agent-24.04", + }, + } + g.Config.Codesphere.DeployConfig = files.DeployConfig{ + Images: map[string]files.ImageConfig{ + "ubuntu-24.04": { + Name: "Ubuntu 24.04", + SupportedUntil: "2028-05-31", + Flavors: map[string]files.FlavorConfig{ + "default": { + Image: files.ImageRef{ + BomRef: "workspace-agent-24.04", + }, + Pool: map[int]int{1: 1}, + }, + }, + }, + }, + } + g.Config.Codesphere.Plans = files.PlansConfig{ + HostingPlans: map[int]files.HostingPlan{ + 1: { + CPUTenth: 10, + GPUParts: 0, + MemoryMb: 2048, + StorageMb: 20480, + TempStorageMb: 1024, + }, + }, + WorkspacePlans: map[int]files.WorkspacePlan{ + 1: { + Name: "Standard", + HostingPlanID: 1, + MaxReplicas: 3, + OnDemand: true, + }, + }, + } + g.Config.ManagedServiceBackends = &files.ManagedServiceBackendsConfig{ + Postgres: make(map[string]interface{}), + } + g.Config.Secrets.BaseDir = "/root/secrets" switch profile { case PROFILE_DEV, PROFILE_DEVELOPMENT: - g.Config.Datacenter.ID = 1 g.Config.Datacenter.Name = "dev" - g.Config.Datacenter.City = "Karlsruhe" - g.Config.Datacenter.CountryCode = "DE" - // TODO: g.config.Postgres.Mode = "install" g.Config.Postgres.Primary.IP = "127.0.0.1" g.Config.Postgres.Primary.Hostname = "localhost" g.Config.Ceph.NodesSubnet = "127.0.0.1/32" g.Config.Ceph.Hosts = []files.CephHost{{Hostname: "localhost", IPAddress: "127.0.0.1", IsMaster: true}} - g.Config.Kubernetes.ManagedByCodesphere = true g.Config.Kubernetes.APIServerHost = "127.0.0.1" g.Config.Kubernetes.ControlPlanes = []files.K8sNode{{IPAddress: "127.0.0.1"}} g.Config.Kubernetes.Workers = []files.K8sNode{{IPAddress: "127.0.0.1"}} - g.Config.Cluster.Gateway = files.GatewayConfig{ServiceType: "LoadBalancer"} - g.Config.Cluster.PublicGateway = files.GatewayConfig{ServiceType: "LoadBalancer"} g.Config.Codesphere.Domain = "codesphere.local" g.Config.Codesphere.WorkspaceHostingBaseDomain = "ws.local" g.Config.Codesphere.CustomDomains.CNameBaseDomain = "custom.local" g.Config.Codesphere.DNSServers = []string{"8.8.8.8", "1.1.1.1"} - g.Config.Codesphere.WorkspaceImages.Agent.BomRef = "workspace-agent-24.04" - g.Config.Codesphere.Plans = files.PlansConfig{ - HostingPlans: map[int]files.HostingPlan{ - 1: { - CPUTenth: 10, - GPUParts: 0, - MemoryMb: 2048, - StorageMb: 20480, - TempStorageMb: 1024, - }, - }, - WorkspacePlans: map[int]files.WorkspacePlan{ - 1: { - Name: "Standard", - HostingPlanID: 1, - MaxReplicas: 3, - OnDemand: true, - }, - }, - } - g.Config.Secrets.BaseDir = "/root/secrets" fmt.Println("Applied 'dev' profile: single-node development setup") case PROFILE_PROD, PROFILE_PRODUCTION: - g.Config.Datacenter.ID = 1 g.Config.Datacenter.Name = "production" - g.Config.Datacenter.City = "Karlsruhe" - g.Config.Datacenter.CountryCode = "DE" - // TODO: g.config.Postgres.Mode = "install" g.Config.Postgres.Primary.IP = "10.50.0.2" g.Config.Postgres.Primary.Hostname = "pg-primary" g.Config.Postgres.Replica.IP = "10.50.0.3" @@ -111,76 +139,41 @@ func (g *InstallConfig) ApplyProfile(profile string) error { {IPAddress: "10.50.0.3"}, {IPAddress: "10.50.0.4"}, } - g.Config.Cluster.Gateway = files.GatewayConfig{ServiceType: "LoadBalancer"} - g.Config.Cluster.PublicGateway = files.GatewayConfig{ServiceType: "LoadBalancer"} g.Config.Codesphere.Domain = "codesphere.yourcompany.com" g.Config.Codesphere.WorkspaceHostingBaseDomain = "ws.yourcompany.com" g.Config.Codesphere.CustomDomains.CNameBaseDomain = "custom.yourcompany.com" g.Config.Codesphere.DNSServers = []string{"1.1.1.1", "8.8.8.8"} - g.Config.Codesphere.WorkspaceImages.Agent.BomRef = "workspace-agent-24.04" - g.Config.Codesphere.Plans = files.PlansConfig{ - HostingPlans: map[int]files.HostingPlan{ - 1: { - CPUTenth: 10, - GPUParts: 0, - MemoryMb: 2048, - StorageMb: 20480, - TempStorageMb: 1024, - }, - }, - WorkspacePlans: map[int]files.WorkspacePlan{ - 1: { - Name: "Standard Developer", - HostingPlanID: 1, - MaxReplicas: 3, - OnDemand: true, - }, + g.Config.Codesphere.Plans.WorkspacePlans = map[int]files.WorkspacePlan{ + 1: { + Name: "Standard Developer", + HostingPlanID: 1, + MaxReplicas: 3, + OnDemand: true, }, } - g.Config.Secrets.BaseDir = "/root/secrets" fmt.Println("Applied 'production' profile: HA multi-node setup") case PROFILE_MINIMAL: - g.Config.Datacenter.ID = 1 g.Config.Datacenter.Name = "minimal" - g.Config.Datacenter.City = "Karlsruhe" - g.Config.Datacenter.CountryCode = "DE" - // TODO: g.config.Postgres.Mode = "install" g.Config.Postgres.Primary.IP = "127.0.0.1" g.Config.Postgres.Primary.Hostname = "localhost" g.Config.Ceph.NodesSubnet = "127.0.0.1/32" g.Config.Ceph.Hosts = []files.CephHost{{Hostname: "localhost", IPAddress: "127.0.0.1", IsMaster: true}} - g.Config.Kubernetes.ManagedByCodesphere = true g.Config.Kubernetes.APIServerHost = "127.0.0.1" g.Config.Kubernetes.ControlPlanes = []files.K8sNode{{IPAddress: "127.0.0.1"}} g.Config.Kubernetes.Workers = []files.K8sNode{} - g.Config.Cluster.Gateway = files.GatewayConfig{ServiceType: "LoadBalancer"} - g.Config.Cluster.PublicGateway = files.GatewayConfig{ServiceType: "LoadBalancer"} g.Config.Codesphere.Domain = "codesphere.local" g.Config.Codesphere.WorkspaceHostingBaseDomain = "ws.local" g.Config.Codesphere.CustomDomains.CNameBaseDomain = "custom.local" g.Config.Codesphere.DNSServers = []string{"8.8.8.8"} - g.Config.Codesphere.WorkspaceImages.Agent.BomRef = "workspace-agent-24.04" - g.Config.Codesphere.Plans = files.PlansConfig{ - HostingPlans: map[int]files.HostingPlan{ - 1: { - CPUTenth: 10, - GPUParts: 0, - MemoryMb: 2048, - StorageMb: 20480, - TempStorageMb: 1024, - }, - }, - WorkspacePlans: map[int]files.WorkspacePlan{ - 1: { - Name: "Standard Developer", - HostingPlanID: 1, - MaxReplicas: 1, - OnDemand: true, - }, + g.Config.Codesphere.Plans.WorkspacePlans = map[int]files.WorkspacePlan{ + 1: { + Name: "Standard Developer", + HostingPlanID: 1, + MaxReplicas: 1, + OnDemand: true, }, } - g.Config.Secrets.BaseDir = "/root/secrets" fmt.Println("Applied 'minimal' profile: minimal single-node setup") default: diff --git a/internal/installer/files/config_yaml.go b/internal/installer/files/config_yaml.go index 3ce244cd..f5e2f85a 100644 --- a/internal/installer/files/config_yaml.go +++ b/internal/installer/files/config_yaml.go @@ -5,7 +5,6 @@ package files import ( "fmt" - "os" "strings" "gopkg.in/yaml.v3" @@ -16,6 +15,14 @@ type InstallVault struct { Secrets []SecretEntry `yaml:"secrets"` } +func (v *InstallVault) Marshal() ([]byte, error) { + return yaml.Marshal(v) +} + +func (v *InstallVault) Unmarshal(data []byte) error { + return yaml.Unmarshal(data, v) +} + type SecretEntry struct { Name string `yaml:"name"` File *SecretFile `yaml:"file,omitempty"` @@ -63,6 +70,7 @@ type RegistryConfig struct { } type PostgresConfig struct { + Mode string `yaml:"mode,omitempty"` CACertPem string `yaml:"caCertPem,omitempty"` Primary *PostgresPrimaryConfig `yaml:"primary,omitempty"` Replica *PostgresReplicaConfig `yaml:"replica,omitempty"` @@ -376,19 +384,14 @@ type MetalLBPool struct { IPAddresses []string } -// TODO: remove duplicate marshal function -func (c *RootConfig) ParseConfig(filePath string) error { - configData, err := os.ReadFile(filePath) - if err != nil { - return fmt.Errorf("failed to read config file: %w", err) - } - - err = yaml.Unmarshal(configData, c) - if err != nil { - return fmt.Errorf("failed to parse YAML config: %w", err) - } +// Marshal serializes the RootConfig to YAML +func (c *RootConfig) Marshal() ([]byte, error) { + return yaml.Marshal(c) +} - return nil +// Unmarshal deserializes YAML data into the RootConfig +func (c *RootConfig) Unmarshal(data []byte) error { + return yaml.Unmarshal(data, c) } func (c *RootConfig) ExtractBomRefs() []string { diff --git a/internal/installer/files/config_yaml_test.go b/internal/installer/files/config_yaml_test.go index 4c235a85..11620754 100644 --- a/internal/installer/files/config_yaml_test.go +++ b/internal/installer/files/config_yaml_test.go @@ -84,7 +84,10 @@ codesphere: err := os.WriteFile(configFile, []byte(sampleYaml), 0644) Expect(err).NotTo(HaveOccurred()) - err = rootConfig.ParseConfig(configFile) + data, err := os.ReadFile(configFile) + Expect(err).NotTo(HaveOccurred()) + + err = rootConfig.Unmarshal(data) Expect(err).NotTo(HaveOccurred()) Expect(rootConfig.Registry.Server).To(Equal("registry.example.com")) @@ -108,9 +111,8 @@ codesphere: }) It("should return error for non-existent file", func() { - err := rootConfig.ParseConfig("/non/existent/config.yaml") + _, err := os.ReadFile("/non/existent/config.yaml") Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to read config file")) }) It("should return error for invalid YAML", func() { @@ -124,16 +126,21 @@ codesphere: err := os.WriteFile(configFile, []byte(invalidYaml), 0644) Expect(err).NotTo(HaveOccurred()) - err = rootConfig.ParseConfig(configFile) + data, err := os.ReadFile(configFile) + Expect(err).NotTo(HaveOccurred()) + + err = rootConfig.Unmarshal(data) Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to parse YAML config")) }) It("should handle empty config file", func() { err := os.WriteFile(configFile, []byte(""), 0644) Expect(err).NotTo(HaveOccurred()) - err = rootConfig.ParseConfig(configFile) + data, err := os.ReadFile(configFile) + Expect(err).NotTo(HaveOccurred()) + + err = rootConfig.Unmarshal(data) Expect(err).NotTo(HaveOccurred()) Expect(rootConfig.Registry.Server).To(BeEmpty()) @@ -150,7 +157,10 @@ codesphere: err := os.WriteFile(configFile, []byte(minimalYaml), 0644) Expect(err).NotTo(HaveOccurred()) - err = rootConfig.ParseConfig(configFile) + data, err := os.ReadFile(configFile) + Expect(err).NotTo(HaveOccurred()) + + err = rootConfig.Unmarshal(data) Expect(err).NotTo(HaveOccurred()) Expect(rootConfig.Registry.Server).To(Equal("minimal.registry.com")) @@ -163,7 +173,10 @@ codesphere: err := os.WriteFile(configFile, []byte(sampleYaml), 0644) Expect(err).NotTo(HaveOccurred()) - err = rootConfig.ParseConfig(configFile) + data, err := os.ReadFile(configFile) + Expect(err).NotTo(HaveOccurred()) + + err = rootConfig.Unmarshal(data) Expect(err).NotTo(HaveOccurred()) }) @@ -204,7 +217,10 @@ codesphere: err := os.WriteFile(configFile, []byte(yamlWithoutBomRefs), 0644) Expect(err).NotTo(HaveOccurred()) - err = noImagesConfig.ParseConfig(configFile) + data, err := os.ReadFile(configFile) + Expect(err).NotTo(HaveOccurred()) + + err = noImagesConfig.Unmarshal(data) Expect(err).NotTo(HaveOccurred()) bomRefs := noImagesConfig.ExtractBomRefs() @@ -217,7 +233,10 @@ codesphere: err := os.WriteFile(configFile, []byte(sampleYaml), 0644) Expect(err).NotTo(HaveOccurred()) - err = rootConfig.ParseConfig(configFile) + data, err := os.ReadFile(configFile) + Expect(err).NotTo(HaveOccurred()) + + err = rootConfig.Unmarshal(data) Expect(err).NotTo(HaveOccurred()) }) From a5ce2029f30603e7bb66c3fc3eacd60a4829f560 Mon Sep 17 00:00:00 2001 From: Simon Herrmann Date: Fri, 14 Nov 2025 09:57:06 +0100 Subject: [PATCH 17/24] refac: refactor and clean up install config generator Co-authored-by: OliverTrautvetter --- cli/cmd/init_install_config.go | 44 +--- .../init_install_config_interactive_test.go | 8 +- cli/cmd/init_install_config_test.go | 4 +- internal/installer/config_manager.go | 219 +++++++++--------- .../{secrets.go => config_manager_secrets.go} | 41 ++-- 5 files changed, 146 insertions(+), 170 deletions(-) rename internal/installer/{secrets.go => config_manager_secrets.go} (56%) diff --git a/cli/cmd/init_install_config.go b/cli/cmd/init_install_config.go index 2de27a45..72f8d0bf 100644 --- a/cli/cmd/init_install_config.go +++ b/cli/cmd/init_install_config.go @@ -5,7 +5,6 @@ package cmd import ( "fmt" - "io" "strings" csio "github.com/codesphere-cloud/cs-go/pkg/io" @@ -164,10 +163,10 @@ func (c *InitInstallConfigCmd) InitInstallConfig(icg installer.InstallConfigMana return fmt.Errorf("failed to collect configuration interactively: %w", err) } } else { - c.updateConfigFromOpts(icg.GetConfig()) + c.updateConfigFromOpts(icg.GetInstallConfig()) } - if err := icg.Validate(); err != nil { + if err := icg.ValidateInstallConfig(); err != nil { return fmt.Errorf("configuration validation failed: %w", err) } @@ -216,47 +215,28 @@ func (c *InitInstallConfigCmd) printSuccessMessage() { func (c *InitInstallConfigCmd) validateOnly(icg installer.InstallConfigManager) error { fmt.Printf("Validating configuration files...\n") - fmt.Printf("Reading config file: %s\n", c.Opts.ConfigFile) - err := icg.LoadConfigFromFile(c.Opts.ConfigFile) + fmt.Printf("Reading install config file: %s\n", c.Opts.ConfigFile) + err := icg.LoadInstallConfigFromFile(c.Opts.ConfigFile) if err != nil { return fmt.Errorf("failed to load config file: %w", err) } - err = icg.Validate() - if err != nil { - return fmt.Errorf("configuration validation failed: %w", err) + errors := icg.ValidateInstallConfig() + if len(errors) > 0 { + return fmt.Errorf("install config validation failed: %s", strings.Join(errors, ", ")) } - var errors []string if c.Opts.VaultFile != "" { fmt.Printf("Reading vault file: %s\n", c.Opts.VaultFile) - vaultFile, err := c.FileWriter.Open(c.Opts.VaultFile) + err := icg.LoadVaultFromFile(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 := &files.InstallVault{} - if err := vault.Unmarshal(vaultData); err != nil { - errors = append(errors, fmt.Sprintf("failed to parse vault.yaml: %v", err)) - } else { - vaultErrors := installer.ValidateVault(vault) - errors = append(errors, vaultErrors...) - } - } + return fmt.Errorf("failed to load vault file: %w", err) } - } - if len(errors) > 0 { - fmt.Println("Validation failed:") - for _, err := range errors { - fmt.Printf(" - %s\n", err) + vaultErrors := icg.ValidateVault() + if len(vaultErrors) > 0 { + return fmt.Errorf("vault validation errors: %s", strings.Join(vaultErrors, ", ")) } - return fmt.Errorf("configuration validation failed with %d error(s)", len(errors)) } fmt.Println("Configuration is valid!") diff --git a/cli/cmd/init_install_config_interactive_test.go b/cli/cmd/init_install_config_interactive_test.go index 6631047c..ad0ec46c 100644 --- a/cli/cmd/init_install_config_interactive_test.go +++ b/cli/cmd/init_install_config_interactive_test.go @@ -22,7 +22,7 @@ var _ = Describe("Interactive profile usage", func() { err := icg.ApplyProfile("dev") Expect(err).NotTo(HaveOccurred()) - config := icg.GetConfig() + config := icg.GetInstallConfig() // Verify that profile values are set correctly Expect(config.Datacenter.ID).To(Equal(1)) @@ -111,10 +111,10 @@ var _ = Describe("Interactive profile usage", func() { Expect(err).NotTo(HaveOccurred()) // Verify config content - err = icg.LoadConfigFromFile(configFile.Name()) + err = icg.LoadInstallConfigFromFile(configFile.Name()) Expect(err).NotTo(HaveOccurred()) - config := icg.GetConfig() + config := icg.GetInstallConfig() Expect(config.Datacenter.Name).To(Equal("dev")) Expect(config.Codesphere.Domain).To(Equal("codesphere.local")) }) @@ -127,7 +127,7 @@ var _ = Describe("Interactive profile usage", func() { err := icg.ApplyProfile("production") Expect(err).NotTo(HaveOccurred()) - config := icg.GetConfig() + config := icg.GetInstallConfig() // Verify production-specific values Expect(config.Datacenter.Name).To(Equal("production")) diff --git a/cli/cmd/init_install_config_test.go b/cli/cmd/init_install_config_test.go index ec72e2e3..e015b765 100644 --- a/cli/cmd/init_install_config_test.go +++ b/cli/cmd/init_install_config_test.go @@ -23,7 +23,7 @@ var _ = Describe("ApplyProfile", func() { Expect(err).To(HaveOccurred()) } else { Expect(err).NotTo(HaveOccurred()) - config := icg.GetConfig() + config := icg.GetInstallConfig() Expect(config.Datacenter.Name).To(Equal(checkDatacenterName)) } }, @@ -41,7 +41,7 @@ var _ = Describe("ApplyProfile", func() { err := icg.ApplyProfile("dev") Expect(err).NotTo(HaveOccurred()) - config := icg.GetConfig() + config := icg.GetInstallConfig() Expect(config.Datacenter.ID).To(Equal(1)) Expect(config.Datacenter.Name).To(Equal("dev")) Expect(config.Postgres.Mode).To(Equal("install")) diff --git a/internal/installer/config_manager.go b/internal/installer/config_manager.go index a69cb777..8ad0dcb7 100644 --- a/internal/installer/config_manager.go +++ b/internal/installer/config_manager.go @@ -7,20 +7,25 @@ import ( "fmt" "io" "net" - "strings" "github.com/codesphere-cloud/oms/internal/installer/files" "github.com/codesphere-cloud/oms/internal/util" ) +func IsValidIP(ip string) bool { + return net.ParseIP(ip) != nil +} + type InstallConfigManager interface { // Profile management ApplyProfile(profile string) error // Configuration management - LoadConfigFromFile(configPath string) error - GetConfig() *files.RootConfig + LoadInstallConfigFromFile(configPath string) error + LoadVaultFromFile(vaultPath string) error + ValidateInstallConfig() []string + ValidateVault() []string + GetInstallConfig() *files.RootConfig CollectInteractively() error - Validate() error // Output GenerateSecrets() error WriteInstallConfig(configPath string, withComments bool) error @@ -30,16 +35,18 @@ type InstallConfigManager interface { type InstallConfig struct { fileIO util.FileIO Config *files.RootConfig + Vault *files.InstallVault } func NewInstallConfigManager() InstallConfigManager { return &InstallConfig{ fileIO: &util.FilesystemWriter{}, - Config: nil, + Config: &files.RootConfig{}, + Vault: &files.InstallVault{}, } } -func (g *InstallConfig) LoadConfigFromFile(configPath string) error { +func (g *InstallConfig) LoadInstallConfigFromFile(configPath string) error { file, err := g.fileIO.Open(configPath) if err != nil { return err @@ -60,115 +67,32 @@ func (g *InstallConfig) LoadConfigFromFile(configPath string) error { return nil } -func (g *InstallConfig) GetConfig() *files.RootConfig { - return g.Config -} - -func (g *InstallConfig) Validate() error { - if g.Config == nil { - return fmt.Errorf("config not set, cannot validate") - } - - errors := g.ValidateConfig() - if len(errors) > 0 { - var errMsg strings.Builder - errMsg.WriteString("configuration validation failed:\n") - for _, err := range errors { - errMsg.WriteString(fmt.Sprintf(" - %s\n", err)) - } - return fmt.Errorf("%s", errMsg.String()) - } - - return nil -} - -func (g *InstallConfig) GenerateSecrets() error { - if g.Config == nil { - return fmt.Errorf("config not set, cannot generate secrets") - } - return g.generateSecrets(g.Config) -} - -func (g *InstallConfig) WriteInstallConfig(configPath string, withComments bool) error { - if g.Config == nil { - return fmt.Errorf("no configuration provided - config is nil") - } - - configYAML, err := g.Config.Marshal() +func (g *InstallConfig) LoadVaultFromFile(vaultPath string) error { + vaultFile, err := g.fileIO.Open(vaultPath) if err != nil { - return fmt.Errorf("failed to marshal config.yaml: %w", err) + return fmt.Errorf("error opening vault file: %v", err) } + defer util.CloseFileIgnoreError(vaultFile) - if withComments { - configYAML = AddConfigComments(configYAML) + vaultData, err := io.ReadAll(vaultFile) + if err != nil { + return fmt.Errorf("failed to read vault.yaml: %v", err) } - if err := g.fileIO.CreateAndWrite(configPath, configYAML, "Configuration"); err != nil { - return err + vault := &files.InstallVault{} + if err := vault.Unmarshal(vaultData); err != nil { + return fmt.Errorf("failed to parse vault.yaml: %v", err) } + g.Vault = vault return nil } -func (g *InstallConfig) WriteVault(vaultPath string, withComments bool) error { +func (g *InstallConfig) ValidateInstallConfig() []string { if g.Config == nil { - return fmt.Errorf("no configuration provided - config is nil") - } - - vault := g.Config.ExtractVault() - vaultYAML, err := vault.Marshal() - if err != nil { - return fmt.Errorf("failed to marshal vault.yaml: %w", err) - } - - if withComments { - vaultYAML = AddVaultComments(vaultYAML) - } - - if err := g.fileIO.CreateAndWrite(vaultPath, vaultYAML, "Secrets"); err != nil { - return err + return []string{"config not set, cannot validate"} } - 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 (g *InstallConfig) ValidateConfig() []string { errors := []string{} if g.Config.Datacenter.ID == 0 { @@ -231,12 +155,16 @@ func (g *InstallConfig) ValidateConfig() []string { return errors } -func ValidateVault(vault *files.InstallVault) []string { +func (g *InstallConfig) ValidateVault() []string { + if g.Vault == nil { + return []string{"vault not set, cannot validate"} + } + errors := []string{} requiredSecrets := []string{"cephSshPrivateKey", "selfSignedCaKeyPem", "domainAuthPrivateKey", "domainAuthPublicKey"} foundSecrets := make(map[string]bool) - for _, secret := range vault.Secrets { + for _, secret := range g.Vault.Secrets { foundSecrets[secret.Name] = true } @@ -249,6 +177,85 @@ func ValidateVault(vault *files.InstallVault) []string { return errors } -func IsValidIP(ip string) bool { - return net.ParseIP(ip) != nil +func (g *InstallConfig) GetInstallConfig() *files.RootConfig { + return g.Config +} + +func (g *InstallConfig) WriteInstallConfig(configPath string, withComments bool) error { + if g.Config == nil { + return fmt.Errorf("no configuration provided - config is nil") + } + + configYAML, err := g.Config.Marshal() + if err != nil { + return fmt.Errorf("failed to marshal config.yaml: %w", err) + } + + if withComments { + configYAML = AddConfigComments(configYAML) + } + + if err := g.fileIO.CreateAndWrite(configPath, configYAML, "Configuration"); err != nil { + return err + } + + return nil +} + +func (g *InstallConfig) WriteVault(vaultPath string, withComments bool) error { + if g.Config == nil { + return fmt.Errorf("no configuration provided - config is nil") + } + + vault := g.Config.ExtractVault() + vaultYAML, err := vault.Marshal() + if err != nil { + return fmt.Errorf("failed to marshal vault.yaml: %w", err) + } + + if withComments { + vaultYAML = AddVaultComments(vaultYAML) + } + + if err := g.fileIO.CreateAndWrite(vaultPath, vaultYAML, "Secrets"); err != nil { + return err + } + + return nil +} + +func AddConfigComments(yamlData []byte) []byte { + header := `# Codesphere Installer Configuration +# Generated by OMS CLI +# +# This file contains the main configuration for installing Codesphere Private Cloud. +# Review and modify as needed before running the installer. +# +# For more information, see the installation documentation. + +` + return append([]byte(header), yamlData...) +} + +func AddVaultComments(yamlData []byte) []byte { + header := `# Codesphere Installer Secrets +# Generated by OMS CLI +# +# IMPORTANT: This file contains sensitive information! +# +# Before storing or transmitting this file: +# 1. Install SOPS and Age: brew install sops age +# 2. Generate an Age keypair: age-keygen -o age_key.txt +# 3. Encrypt this file: +# age-keygen -y age_key.txt # Get public key +# sops --encrypt --age --in-place prod.vault.yaml +# +# Keep the Age private key (age_key.txt) extremely secure! +# +# To edit the encrypted file later: +# export SOPS_AGE_KEY_FILE=/path/to/age_key.txt +# sops prod.vault.yaml + +` + return append([]byte(header), yamlData...) } diff --git a/internal/installer/secrets.go b/internal/installer/config_manager_secrets.go similarity index 56% rename from internal/installer/secrets.go rename to internal/installer/config_manager_secrets.go index a4f225db..dce42dd4 100644 --- a/internal/installer/secrets.go +++ b/internal/installer/config_manager_secrets.go @@ -9,33 +9,28 @@ import ( "github.com/codesphere-cloud/oms/internal/installer/files" ) -func (g *InstallConfig) generateSecrets(config *files.RootConfig) error { +func (g *InstallConfig) GenerateSecrets() error { fmt.Println("Generating domain authentication keys...") - domainAuthPub, domainAuthPriv, err := GenerateECDSAKeyPair() + var err error + g.Config.Codesphere.DomainAuthPublicKey, g.Config.Codesphere.DomainAuthPrivateKey, err = GenerateECDSAKeyPair() if err != nil { return fmt.Errorf("failed to generate domain auth keys: %w", err) } - config.Codesphere.DomainAuthPublicKey = domainAuthPub - config.Codesphere.DomainAuthPrivateKey = domainAuthPriv fmt.Println("Generating ingress CA certificate...") - ingressCAKey, ingressCACert, err := GenerateCA("Cluster Ingress CA", "DE", "Karlsruhe", "Codesphere") + g.Config.Cluster.IngressCAKey, g.Config.Cluster.Certificates.CA.CertPem, err = GenerateCA("Cluster Ingress CA", "DE", "Karlsruhe", "Codesphere") if err != nil { return fmt.Errorf("failed to generate ingress CA: %w", err) } - config.Cluster.Certificates.CA.CertPem = ingressCACert - config.Cluster.IngressCAKey = ingressCAKey fmt.Println("Generating Ceph SSH keys...") - cephSSHPub, cephSSHPriv, err := GenerateSSHKeyPair() + g.Config.Ceph.CephAdmSSHKey.PublicKey, g.Config.Ceph.SshPrivateKey, err = GenerateSSHKeyPair() if err != nil { return fmt.Errorf("failed to generate Ceph SSH keys: %w", err) } - config.Ceph.CephAdmSSHKey.PublicKey = cephSSHPub - config.Ceph.SshPrivateKey = cephSSHPriv - if config.Postgres.Primary != nil { - if err := g.generatePostgresSecrets(config); err != nil { + if g.Config.Postgres.Primary != nil { + if err := g.generatePostgresSecrets(g.Config); err != nil { return err } } @@ -45,41 +40,35 @@ func (g *InstallConfig) generateSecrets(config *files.RootConfig) error { func (g *InstallConfig) generatePostgresSecrets(config *files.RootConfig) error { fmt.Println("Generating PostgreSQL certificates and passwords...") - - pgCAKey, pgCACert, err := GenerateCA("PostgreSQL CA", "DE", "Karlsruhe", "Codesphere") + var err error + config.Postgres.CaCertPrivateKey, config.Postgres.CACertPem, err = GenerateCA("PostgreSQL CA", "DE", "Karlsruhe", "Codesphere") if err != nil { return fmt.Errorf("failed to generate PostgreSQL CA: %w", err) } - config.Postgres.CACertPem = pgCACert - config.Postgres.CaCertPrivateKey = pgCAKey - pgPrimaryKey, pgPrimaryCert, err := GenerateServerCertificate( - pgCAKey, - pgCACert, + config.Postgres.Primary.PrivateKey, config.Postgres.Primary.SSLConfig.ServerCertPem, err = GenerateServerCertificate( + config.Postgres.CaCertPrivateKey, + config.Postgres.CACertPem, config.Postgres.Primary.Hostname, []string{config.Postgres.Primary.IP}, ) if err != nil { return fmt.Errorf("failed to generate primary PostgreSQL certificate: %w", err) } - config.Postgres.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.PrivateKey, config.Postgres.Replica.SSLConfig.ServerCertPem, err = GenerateServerCertificate( + config.Postgres.CaCertPrivateKey, + config.Postgres.CACertPem, config.Postgres.Replica.Name, []string{config.Postgres.Replica.IP}, ) if err != nil { return fmt.Errorf("failed to generate replica PostgreSQL certificate: %w", err) } - config.Postgres.Replica.SSLConfig.ServerCertPem = pgReplicaCert - config.Postgres.Replica.PrivateKey = pgReplicaKey } services := []string{"auth", "deployment", "ide", "marketplace", "payment", "public_api", "team", "workspace"} From 01cdb7f874177807ba5d5027fde80c06758cf7a5 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Fri, 14 Nov 2025 08:58:16 +0000 Subject: [PATCH 18/24] chore(docs): Auto-update docs and licenses Signed-off-by: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> --- internal/installer/mocks.go | 167 ++++++++++++++++++++++++++++-------- 1 file changed, 130 insertions(+), 37 deletions(-) diff --git a/internal/installer/mocks.go b/internal/installer/mocks.go index bdec512e..a9adf6e7 100644 --- a/internal/installer/mocks.go +++ b/internal/installer/mocks.go @@ -251,12 +251,12 @@ func (_c *MockInstallConfigManager_GenerateSecrets_Call) RunAndReturn(run func() return _c } -// GetConfig provides a mock function for the type MockInstallConfigManager -func (_mock *MockInstallConfigManager) GetConfig() *files.RootConfig { +// GetInstallConfig provides a mock function for the type MockInstallConfigManager +func (_mock *MockInstallConfigManager) GetInstallConfig() *files.RootConfig { ret := _mock.Called() if len(ret) == 0 { - panic("no return value specified for GetConfig") + panic("no return value specified for GetInstallConfig") } var r0 *files.RootConfig @@ -270,39 +270,39 @@ func (_mock *MockInstallConfigManager) GetConfig() *files.RootConfig { return r0 } -// MockInstallConfigManager_GetConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetConfig' -type MockInstallConfigManager_GetConfig_Call struct { +// MockInstallConfigManager_GetInstallConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetInstallConfig' +type MockInstallConfigManager_GetInstallConfig_Call struct { *mock.Call } -// GetConfig is a helper method to define mock.On call -func (_e *MockInstallConfigManager_Expecter) GetConfig() *MockInstallConfigManager_GetConfig_Call { - return &MockInstallConfigManager_GetConfig_Call{Call: _e.mock.On("GetConfig")} +// GetInstallConfig is a helper method to define mock.On call +func (_e *MockInstallConfigManager_Expecter) GetInstallConfig() *MockInstallConfigManager_GetInstallConfig_Call { + return &MockInstallConfigManager_GetInstallConfig_Call{Call: _e.mock.On("GetInstallConfig")} } -func (_c *MockInstallConfigManager_GetConfig_Call) Run(run func()) *MockInstallConfigManager_GetConfig_Call { +func (_c *MockInstallConfigManager_GetInstallConfig_Call) Run(run func()) *MockInstallConfigManager_GetInstallConfig_Call { _c.Call.Run(func(args mock.Arguments) { run() }) return _c } -func (_c *MockInstallConfigManager_GetConfig_Call) Return(rootConfig *files.RootConfig) *MockInstallConfigManager_GetConfig_Call { +func (_c *MockInstallConfigManager_GetInstallConfig_Call) Return(rootConfig *files.RootConfig) *MockInstallConfigManager_GetInstallConfig_Call { _c.Call.Return(rootConfig) return _c } -func (_c *MockInstallConfigManager_GetConfig_Call) RunAndReturn(run func() *files.RootConfig) *MockInstallConfigManager_GetConfig_Call { +func (_c *MockInstallConfigManager_GetInstallConfig_Call) RunAndReturn(run func() *files.RootConfig) *MockInstallConfigManager_GetInstallConfig_Call { _c.Call.Return(run) return _c } -// LoadConfigFromFile provides a mock function for the type MockInstallConfigManager -func (_mock *MockInstallConfigManager) LoadConfigFromFile(configPath string) error { +// LoadInstallConfigFromFile provides a mock function for the type MockInstallConfigManager +func (_mock *MockInstallConfigManager) LoadInstallConfigFromFile(configPath string) error { ret := _mock.Called(configPath) if len(ret) == 0 { - panic("no return value specified for LoadConfigFromFile") + panic("no return value specified for LoadInstallConfigFromFile") } var r0 error @@ -314,74 +314,167 @@ func (_mock *MockInstallConfigManager) LoadConfigFromFile(configPath string) err return r0 } -// MockInstallConfigManager_LoadConfigFromFile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LoadConfigFromFile' -type MockInstallConfigManager_LoadConfigFromFile_Call struct { +// MockInstallConfigManager_LoadInstallConfigFromFile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LoadInstallConfigFromFile' +type MockInstallConfigManager_LoadInstallConfigFromFile_Call struct { *mock.Call } -// LoadConfigFromFile is a helper method to define mock.On call +// LoadInstallConfigFromFile is a helper method to define mock.On call // - configPath -func (_e *MockInstallConfigManager_Expecter) LoadConfigFromFile(configPath interface{}) *MockInstallConfigManager_LoadConfigFromFile_Call { - return &MockInstallConfigManager_LoadConfigFromFile_Call{Call: _e.mock.On("LoadConfigFromFile", configPath)} +func (_e *MockInstallConfigManager_Expecter) LoadInstallConfigFromFile(configPath interface{}) *MockInstallConfigManager_LoadInstallConfigFromFile_Call { + return &MockInstallConfigManager_LoadInstallConfigFromFile_Call{Call: _e.mock.On("LoadInstallConfigFromFile", configPath)} } -func (_c *MockInstallConfigManager_LoadConfigFromFile_Call) Run(run func(configPath string)) *MockInstallConfigManager_LoadConfigFromFile_Call { +func (_c *MockInstallConfigManager_LoadInstallConfigFromFile_Call) Run(run func(configPath string)) *MockInstallConfigManager_LoadInstallConfigFromFile_Call { _c.Call.Run(func(args mock.Arguments) { run(args[0].(string)) }) return _c } -func (_c *MockInstallConfigManager_LoadConfigFromFile_Call) Return(err error) *MockInstallConfigManager_LoadConfigFromFile_Call { +func (_c *MockInstallConfigManager_LoadInstallConfigFromFile_Call) Return(err error) *MockInstallConfigManager_LoadInstallConfigFromFile_Call { _c.Call.Return(err) return _c } -func (_c *MockInstallConfigManager_LoadConfigFromFile_Call) RunAndReturn(run func(configPath string) error) *MockInstallConfigManager_LoadConfigFromFile_Call { +func (_c *MockInstallConfigManager_LoadInstallConfigFromFile_Call) RunAndReturn(run func(configPath string) error) *MockInstallConfigManager_LoadInstallConfigFromFile_Call { _c.Call.Return(run) return _c } -// Validate provides a mock function for the type MockInstallConfigManager -func (_mock *MockInstallConfigManager) Validate() error { - ret := _mock.Called() +// LoadVaultFromFile provides a mock function for the type MockInstallConfigManager +func (_mock *MockInstallConfigManager) LoadVaultFromFile(vaultPath string) error { + ret := _mock.Called(vaultPath) if len(ret) == 0 { - panic("no return value specified for Validate") + panic("no return value specified for LoadVaultFromFile") } var r0 error - if returnFunc, ok := ret.Get(0).(func() error); ok { - r0 = returnFunc() + if returnFunc, ok := ret.Get(0).(func(string) error); ok { + r0 = returnFunc(vaultPath) } else { r0 = ret.Error(0) } return r0 } -// MockInstallConfigManager_Validate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Validate' -type MockInstallConfigManager_Validate_Call struct { +// MockInstallConfigManager_LoadVaultFromFile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LoadVaultFromFile' +type MockInstallConfigManager_LoadVaultFromFile_Call struct { *mock.Call } -// Validate is a helper method to define mock.On call -func (_e *MockInstallConfigManager_Expecter) Validate() *MockInstallConfigManager_Validate_Call { - return &MockInstallConfigManager_Validate_Call{Call: _e.mock.On("Validate")} +// LoadVaultFromFile is a helper method to define mock.On call +// - vaultPath +func (_e *MockInstallConfigManager_Expecter) LoadVaultFromFile(vaultPath interface{}) *MockInstallConfigManager_LoadVaultFromFile_Call { + return &MockInstallConfigManager_LoadVaultFromFile_Call{Call: _e.mock.On("LoadVaultFromFile", vaultPath)} } -func (_c *MockInstallConfigManager_Validate_Call) Run(run func()) *MockInstallConfigManager_Validate_Call { +func (_c *MockInstallConfigManager_LoadVaultFromFile_Call) Run(run func(vaultPath string)) *MockInstallConfigManager_LoadVaultFromFile_Call { _c.Call.Run(func(args mock.Arguments) { - run() + run(args[0].(string)) }) return _c } -func (_c *MockInstallConfigManager_Validate_Call) Return(err error) *MockInstallConfigManager_Validate_Call { +func (_c *MockInstallConfigManager_LoadVaultFromFile_Call) Return(err error) *MockInstallConfigManager_LoadVaultFromFile_Call { _c.Call.Return(err) return _c } -func (_c *MockInstallConfigManager_Validate_Call) RunAndReturn(run func() error) *MockInstallConfigManager_Validate_Call { +func (_c *MockInstallConfigManager_LoadVaultFromFile_Call) RunAndReturn(run func(vaultPath string) error) *MockInstallConfigManager_LoadVaultFromFile_Call { + _c.Call.Return(run) + return _c +} + +// ValidateInstallConfig provides a mock function for the type MockInstallConfigManager +func (_mock *MockInstallConfigManager) ValidateInstallConfig() []string { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for ValidateInstallConfig") + } + + var r0 []string + if returnFunc, ok := ret.Get(0).(func() []string); ok { + r0 = returnFunc() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + return r0 +} + +// MockInstallConfigManager_ValidateInstallConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ValidateInstallConfig' +type MockInstallConfigManager_ValidateInstallConfig_Call struct { + *mock.Call +} + +// ValidateInstallConfig is a helper method to define mock.On call +func (_e *MockInstallConfigManager_Expecter) ValidateInstallConfig() *MockInstallConfigManager_ValidateInstallConfig_Call { + return &MockInstallConfigManager_ValidateInstallConfig_Call{Call: _e.mock.On("ValidateInstallConfig")} +} + +func (_c *MockInstallConfigManager_ValidateInstallConfig_Call) Run(run func()) *MockInstallConfigManager_ValidateInstallConfig_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockInstallConfigManager_ValidateInstallConfig_Call) Return(strings []string) *MockInstallConfigManager_ValidateInstallConfig_Call { + _c.Call.Return(strings) + return _c +} + +func (_c *MockInstallConfigManager_ValidateInstallConfig_Call) RunAndReturn(run func() []string) *MockInstallConfigManager_ValidateInstallConfig_Call { + _c.Call.Return(run) + return _c +} + +// ValidateVault provides a mock function for the type MockInstallConfigManager +func (_mock *MockInstallConfigManager) ValidateVault() []string { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for ValidateVault") + } + + var r0 []string + if returnFunc, ok := ret.Get(0).(func() []string); ok { + r0 = returnFunc() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + return r0 +} + +// MockInstallConfigManager_ValidateVault_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ValidateVault' +type MockInstallConfigManager_ValidateVault_Call struct { + *mock.Call +} + +// ValidateVault is a helper method to define mock.On call +func (_e *MockInstallConfigManager_Expecter) ValidateVault() *MockInstallConfigManager_ValidateVault_Call { + return &MockInstallConfigManager_ValidateVault_Call{Call: _e.mock.On("ValidateVault")} +} + +func (_c *MockInstallConfigManager_ValidateVault_Call) Run(run func()) *MockInstallConfigManager_ValidateVault_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockInstallConfigManager_ValidateVault_Call) Return(strings []string) *MockInstallConfigManager_ValidateVault_Call { + _c.Call.Return(strings) + return _c +} + +func (_c *MockInstallConfigManager_ValidateVault_Call) RunAndReturn(run func() []string) *MockInstallConfigManager_ValidateVault_Call { _c.Call.Return(run) return _c } From bca34445899790be20fe2e98cfb57b7af3fc1bea Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Fri, 14 Nov 2025 10:38:23 +0100 Subject: [PATCH 19/24] fix: install config errors --- cli/cmd/init_install_config.go | 5 +++-- .../installer/config_generator_collector.go | 19 ++++++++++++++++--- internal/installer/config_manager_profile.go | 4 ++++ 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/cli/cmd/init_install_config.go b/cli/cmd/init_install_config.go index 72f8d0bf..10b6b135 100644 --- a/cli/cmd/init_install_config.go +++ b/cli/cmd/init_install_config.go @@ -166,8 +166,9 @@ func (c *InitInstallConfigCmd) InitInstallConfig(icg installer.InstallConfigMana c.updateConfigFromOpts(icg.GetInstallConfig()) } - if err := icg.ValidateInstallConfig(); err != nil { - return fmt.Errorf("configuration validation failed: %w", err) + errors := icg.ValidateInstallConfig() + if len(errors) > 0 { + return fmt.Errorf("configuration validation failed: %s", strings.Join(errors, ", ")) } if err := icg.GenerateSecrets(); err != nil { diff --git a/internal/installer/config_generator_collector.go b/internal/installer/config_generator_collector.go index 5181cb12..349589da 100644 --- a/internal/installer/config_generator_collector.go +++ b/internal/installer/config_generator_collector.go @@ -128,8 +128,9 @@ func (g *InstallConfig) collectCephConfig(prompter *Prompter) { g.Config.Ceph.Hosts[i].IsMaster = (i == 0) } } else { - g.Config.Ceph.Hosts = make([]files.CephHost, len(g.Config.Ceph.Hosts)) - for i, host := range g.Config.Ceph.Hosts { + existingHosts := g.Config.Ceph.Hosts + g.Config.Ceph.Hosts = make([]files.CephHost, len(existingHosts)) + for i, host := range existingHosts { g.Config.Ceph.Hosts[i] = files.CephHost(host) } } @@ -223,7 +224,19 @@ func (g *InstallConfig) collectCodesphereConfig(prompter *Prompter) { g.Config.Codesphere.DNSServers = g.collectStringSlice(prompter, "DNS servers (comma-separated)", []string{"1.1.1.1", "8.8.8.8"}) fmt.Println("\n=== Workspace Plans Configuration ===") - g.Config.Codesphere.WorkspaceImages.Agent.BomRef = g.collectString(prompter, "Workspace agent image BOM reference", "workspace-agent-24.04") + + if g.Config.Codesphere.WorkspaceImages == nil { + g.Config.Codesphere.WorkspaceImages = &files.WorkspaceImagesConfig{} + } + if g.Config.Codesphere.WorkspaceImages.Agent == nil { + g.Config.Codesphere.WorkspaceImages.Agent = &files.ImageRef{} + } + + defaultBomRef := g.Config.Codesphere.WorkspaceImages.Agent.BomRef + if defaultBomRef == "" { + defaultBomRef = "workspace-agent-24.04" + } + g.Config.Codesphere.WorkspaceImages.Agent.BomRef = g.collectString(prompter, "Workspace agent image BOM reference", defaultBomRef) hostingPlan := files.HostingPlan{} hostingPlan.CPUTenth = g.collectInt(prompter, "Hosting plan CPU (tenths, e.g., 10 = 1 core)", 10) hostingPlan.MemoryMb = g.collectInt(prompter, "Hosting plan memory (MB)", 2048) diff --git a/internal/installer/config_manager_profile.go b/internal/installer/config_manager_profile.go index 9033d0bf..4d0b55e2 100644 --- a/internal/installer/config_manager_profile.go +++ b/internal/installer/config_manager_profile.go @@ -55,6 +55,10 @@ func (g *InstallConfig) ApplyProfile(profile string) error { } g.Config.Cluster.Gateway = files.GatewayConfig{ServiceType: "LoadBalancer"} g.Config.Cluster.PublicGateway = files.GatewayConfig{ServiceType: "LoadBalancer"} + g.Config.MetalLB = &files.MetalLBConfig{ + Enabled: false, + Pools: []files.MetalLBPoolDef{}, + } g.Config.Codesphere.Experiments = []string{} g.Config.Codesphere.WorkspaceImages = &files.WorkspaceImagesConfig{ Agent: &files.ImageRef{ From b0b01f0990e48443a7b39769b6ac73a74d11f865 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Fri, 14 Nov 2025 14:26:41 +0100 Subject: [PATCH 20/24] feat: add more tests --- .../config_generator_collector_test.go | 136 +++++ .../installer/config_manager_profile_test.go | 322 ++++++++++++ .../installer/config_manager_secrets_test.go | 222 +++++++++ internal/installer/config_manager_test.go | 463 ++++++++++++++++++ internal/installer/config_test.go | 2 +- 5 files changed, 1144 insertions(+), 1 deletion(-) create mode 100644 internal/installer/config_generator_collector_test.go create mode 100644 internal/installer/config_manager_profile_test.go create mode 100644 internal/installer/config_manager_secrets_test.go create mode 100644 internal/installer/config_manager_test.go diff --git a/internal/installer/config_generator_collector_test.go b/internal/installer/config_generator_collector_test.go new file mode 100644 index 00000000..909223fb --- /dev/null +++ b/internal/installer/config_generator_collector_test.go @@ -0,0 +1,136 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/codesphere-cloud/oms/internal/installer" +) + +var _ = Describe("ConfigGeneratorCollector", func() { + var ( + manager installer.InstallConfigManager + ) + + BeforeEach(func() { + manager = installer.NewInstallConfigManager() + }) + + Describe("CollectInteractively", func() { + It("should collect configuration after applying profile", func() { + err := manager.ApplyProfile("dev") + Expect(err).ToNot(HaveOccurred()) + + err = manager.CollectInteractively() + Expect(err).ToNot(HaveOccurred()) + + config := manager.GetInstallConfig() + Expect(config).ToNot(BeNil()) + Expect(config.Datacenter.Name).ToNot(BeEmpty()) + }) + }) + + Describe("Prompter", func() { + var prompter *installer.Prompter + + Context("Non-interactive mode", func() { + BeforeEach(func() { + prompter = installer.NewPrompter(false) + }) + + It("should return default string value", func() { + result := prompter.String("Test", "default") + Expect(result).To(Equal("default")) + }) + + It("should return default int value", func() { + result := prompter.Int("Test", 42) + Expect(result).To(Equal(42)) + }) + + It("should return default bool value", func() { + result := prompter.Bool("Test", true) + Expect(result).To(BeTrue()) + + result = prompter.Bool("Test", false) + Expect(result).To(BeFalse()) + }) + + It("should return default string slice value", func() { + defaultVal := []string{"a", "b", "c"} + result := prompter.StringSlice("Test", defaultVal) + Expect(result).To(Equal(defaultVal)) + }) + + It("should return default choice value", func() { + result := prompter.Choice("Test", []string{"opt1", "opt2", "opt3"}, "opt2") + Expect(result).To(Equal("opt2")) + }) + + It("should handle empty default values", func() { + result := prompter.String("Test", "") + Expect(result).To(Equal("")) + }) + + It("should handle zero default values", func() { + result := prompter.Int("Test", 0) + Expect(result).To(Equal(0)) + }) + + It("should handle empty slice defaults", func() { + result := prompter.StringSlice("Test", []string{}) + Expect(result).To(Equal([]string{})) + }) + }) + }) + + Describe("Configuration Fields After Collection", func() { + BeforeEach(func() { + manager.ApplyProfile("prod") + manager.CollectInteractively() + }) + + It("should have datacenter configuration", func() { + config := manager.GetInstallConfig() + Expect(config.Datacenter.ID).ToNot(BeZero()) + Expect(config.Datacenter.Name).ToNot(BeEmpty()) + Expect(config.Datacenter.City).ToNot(BeEmpty()) + Expect(config.Datacenter.CountryCode).ToNot(BeEmpty()) + }) + + It("should have PostgreSQL configuration", func() { + config := manager.GetInstallConfig() + Expect(config.Postgres.Mode).ToNot(BeEmpty()) + if config.Postgres.Mode == "install" { + Expect(config.Postgres.Primary).ToNot(BeNil()) + } + }) + + It("should have Ceph configuration", func() { + config := manager.GetInstallConfig() + Expect(config.Ceph.Hosts).ToNot(BeEmpty()) + Expect(config.Ceph.NodesSubnet).ToNot(BeEmpty()) + }) + + It("should have Kubernetes configuration", func() { + config := manager.GetInstallConfig() + if config.Kubernetes.ManagedByCodesphere { + Expect(config.Kubernetes.ControlPlanes).ToNot(BeEmpty()) + } else { + Expect(config.Kubernetes.PodCIDR).ToNot(BeEmpty()) + Expect(config.Kubernetes.ServiceCIDR).ToNot(BeEmpty()) + } + }) + + It("should have Codesphere configuration", func() { + config := manager.GetInstallConfig() + Expect(config.Codesphere.Domain).ToNot(BeEmpty()) + Expect(config.Codesphere.WorkspaceHostingBaseDomain).ToNot(BeEmpty()) + Expect(config.Codesphere.Plans.HostingPlans).ToNot(BeEmpty()) + Expect(config.Codesphere.Plans.WorkspacePlans).ToNot(BeEmpty()) + }) + }) +}) diff --git a/internal/installer/config_manager_profile_test.go b/internal/installer/config_manager_profile_test.go new file mode 100644 index 00000000..58708c8b --- /dev/null +++ b/internal/installer/config_manager_profile_test.go @@ -0,0 +1,322 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/codesphere-cloud/oms/internal/installer" +) + +var _ = Describe("ConfigManagerProfile", func() { + var ( + manager installer.InstallConfigManager + ) + + BeforeEach(func() { + manager = installer.NewInstallConfigManager() + }) + + Describe("ApplyProfile", func() { + Context("with dev profile", func() { + It("should configure single-node development setup", func() { + err := manager.ApplyProfile(installer.PROFILE_DEV) + Expect(err).ToNot(HaveOccurred()) + + config := manager.GetInstallConfig() + Expect(config.Datacenter.Name).To(Equal("dev")) + Expect(config.Postgres.Primary.IP).To(Equal("127.0.0.1")) + Expect(config.Postgres.Primary.Hostname).To(Equal("localhost")) + Expect(config.Ceph.NodesSubnet).To(Equal("127.0.0.1/32")) + Expect(config.Ceph.Hosts).To(HaveLen(1)) + Expect(config.Ceph.Hosts[0].IPAddress).To(Equal("127.0.0.1")) + Expect(config.Ceph.Hosts[0].IsMaster).To(BeTrue()) + Expect(config.Kubernetes.APIServerHost).To(Equal("127.0.0.1")) + Expect(config.Kubernetes.ControlPlanes).To(HaveLen(1)) + Expect(config.Kubernetes.Workers).To(HaveLen(1)) + Expect(config.Codesphere.Domain).To(Equal("codesphere.local")) + }) + + It("should set managed Kubernetes configuration", func() { + err := manager.ApplyProfile(installer.PROFILE_DEV) + Expect(err).ToNot(HaveOccurred()) + + config := manager.GetInstallConfig() + Expect(config.Kubernetes.ManagedByCodesphere).To(BeTrue()) + Expect(config.Kubernetes.NeedsKubeConfig).To(BeFalse()) + }) + }) + + Context("with development profile", func() { + It("should apply development profile as alias for dev", func() { + err := manager.ApplyProfile(installer.PROFILE_DEVELOPMENT) + Expect(err).ToNot(HaveOccurred()) + + config := manager.GetInstallConfig() + Expect(config.Datacenter.Name).To(Equal("dev")) + }) + }) + + Context("with prod profile", func() { + It("should configure HA multi-node setup", func() { + err := manager.ApplyProfile(installer.PROFILE_PROD) + Expect(err).ToNot(HaveOccurred()) + + config := manager.GetInstallConfig() + Expect(config.Datacenter.Name).To(Equal("production")) + Expect(config.Postgres.Primary.IP).To(Equal("10.50.0.2")) + Expect(config.Postgres.Primary.Hostname).To(Equal("pg-primary")) + Expect(config.Postgres.Replica).ToNot(BeNil()) + Expect(config.Postgres.Replica.IP).To(Equal("10.50.0.3")) + }) + + It("should configure multiple Ceph nodes", func() { + err := manager.ApplyProfile(installer.PROFILE_PROD) + Expect(err).ToNot(HaveOccurred()) + + config := manager.GetInstallConfig() + Expect(config.Ceph.Hosts).To(HaveLen(3)) + Expect(config.Ceph.Hosts[0].IsMaster).To(BeTrue()) + Expect(config.Ceph.Hosts[1].IsMaster).To(BeFalse()) + Expect(config.Ceph.Hosts[2].IsMaster).To(BeFalse()) + }) + + It("should configure multiple Kubernetes nodes", func() { + err := manager.ApplyProfile(installer.PROFILE_PROD) + Expect(err).ToNot(HaveOccurred()) + + config := manager.GetInstallConfig() + Expect(config.Kubernetes.ControlPlanes).To(HaveLen(1)) + Expect(config.Kubernetes.Workers).To(HaveLen(3)) + }) + + It("should use production domain names", func() { + err := manager.ApplyProfile(installer.PROFILE_PROD) + Expect(err).ToNot(HaveOccurred()) + + config := manager.GetInstallConfig() + Expect(config.Codesphere.Domain).To(Equal("codesphere.yourcompany.com")) + Expect(config.Codesphere.WorkspaceHostingBaseDomain).To(Equal("ws.yourcompany.com")) + }) + }) + + Context("with production profile", func() { + It("should apply production profile as alias for prod", func() { + err := manager.ApplyProfile(installer.PROFILE_PRODUCTION) + Expect(err).ToNot(HaveOccurred()) + + config := manager.GetInstallConfig() + Expect(config.Datacenter.Name).To(Equal("production")) + }) + }) + + Context("with minimal profile", func() { + It("should configure minimal single-node setup", func() { + err := manager.ApplyProfile(installer.PROFILE_MINIMAL) + Expect(err).ToNot(HaveOccurred()) + + config := manager.GetInstallConfig() + Expect(config.Datacenter.Name).To(Equal("minimal")) + Expect(config.Postgres.Primary.IP).To(Equal("127.0.0.1")) + Expect(config.Kubernetes.Workers).To(BeEmpty()) + }) + + It("should configure minimal workspace plan", func() { + err := manager.ApplyProfile(installer.PROFILE_MINIMAL) + Expect(err).ToNot(HaveOccurred()) + + config := manager.GetInstallConfig() + plan := config.Codesphere.Plans.WorkspacePlans[1] + Expect(plan.MaxReplicas).To(Equal(1)) + }) + }) + + Context("with unknown profile", func() { + It("should return error for unknown profile", func() { + err := manager.ApplyProfile("unknown") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("unknown profile")) + }) + }) + + Context("common configuration across all profiles", func() { + profiles := []string{ + installer.PROFILE_DEV, + installer.PROFILE_PROD, + installer.PROFILE_MINIMAL, + } + + for _, profile := range profiles { + It("should set common configuration for "+profile, func() { + err := manager.ApplyProfile(profile) + Expect(err).ToNot(HaveOccurred()) + + config := manager.GetInstallConfig() + // Datacenter config + Expect(config.Datacenter.ID).To(Equal(1)) + Expect(config.Datacenter.City).To(Equal("Karlsruhe")) + Expect(config.Datacenter.CountryCode).To(Equal("DE")) + + // PostgreSQL mode + Expect(config.Postgres.Mode).To(Equal("install")) + Expect(config.Postgres.Primary).ToNot(BeNil()) + + // Kubernetes + Expect(config.Kubernetes.ManagedByCodesphere).To(BeTrue()) + Expect(config.Kubernetes.NeedsKubeConfig).To(BeFalse()) + + // Cluster certificates + Expect(config.Cluster.Certificates.CA.Algorithm).To(Equal("RSA")) + Expect(config.Cluster.Certificates.CA.KeySizeBits).To(Equal(2048)) + + // Gateway + Expect(config.Cluster.Gateway.ServiceType).To(Equal("LoadBalancer")) + Expect(config.Cluster.PublicGateway.ServiceType).To(Equal("LoadBalancer")) + + // MetalLB + Expect(config.MetalLB).ToNot(BeNil()) + Expect(config.MetalLB.Enabled).To(BeFalse()) + + // Secrets + Expect(config.Secrets.BaseDir).To(Equal("/root/secrets")) + }) + + It("should configure Ceph OSDs for "+profile, func() { + err := manager.ApplyProfile(profile) + Expect(err).ToNot(HaveOccurred()) + + config := manager.GetInstallConfig() + Expect(config.Ceph.OSDs).To(HaveLen(1)) + osd := config.Ceph.OSDs[0] + Expect(osd.SpecID).To(Equal("default")) + Expect(osd.Placement.HostPattern).To(Equal("*")) + Expect(osd.DataDevices.Size).To(Equal("240G:300G")) + Expect(osd.DataDevices.Limit).To(Equal(1)) + Expect(osd.DBDevices.Size).To(Equal("120G:150G")) + Expect(osd.DBDevices.Limit).To(Equal(1)) + }) + + It("should configure workspace images for "+profile, func() { + err := manager.ApplyProfile(profile) + Expect(err).ToNot(HaveOccurred()) + + config := manager.GetInstallConfig() + Expect(config.Codesphere.WorkspaceImages).ToNot(BeNil()) + Expect(config.Codesphere.WorkspaceImages.Agent).ToNot(BeNil()) + Expect(config.Codesphere.WorkspaceImages.Agent.BomRef).To(Equal("workspace-agent-24.04")) + }) + + It("should configure deploy config for "+profile, func() { + err := manager.ApplyProfile(profile) + Expect(err).ToNot(HaveOccurred()) + + config := manager.GetInstallConfig() + images := config.Codesphere.DeployConfig.Images + Expect(images).To(HaveKey("ubuntu-24.04")) + ubuntu := images["ubuntu-24.04"] + Expect(ubuntu.Name).To(Equal("Ubuntu 24.04")) + Expect(ubuntu.SupportedUntil).To(Equal("2028-05-31")) + Expect(ubuntu.Flavors).To(HaveKey("default")) + }) + + It("should configure hosting plans for "+profile, func() { + err := manager.ApplyProfile(profile) + Expect(err).ToNot(HaveOccurred()) + + config := manager.GetInstallConfig() + hostingPlans := config.Codesphere.Plans.HostingPlans + Expect(hostingPlans).To(HaveKey(1)) + plan := hostingPlans[1] + Expect(plan.CPUTenth).To(Equal(10)) + Expect(plan.MemoryMb).To(Equal(2048)) + Expect(plan.StorageMb).To(Equal(20480)) + Expect(plan.TempStorageMb).To(Equal(1024)) + }) + + It("should configure workspace plans for "+profile, func() { + err := manager.ApplyProfile(profile) + Expect(err).ToNot(HaveOccurred()) + + config := manager.GetInstallConfig() + workspacePlans := config.Codesphere.Plans.WorkspacePlans + Expect(workspacePlans).To(HaveKey(1)) + plan := workspacePlans[1] + Expect(plan.HostingPlanID).To(Equal(1)) + Expect(plan.OnDemand).To(BeTrue()) + }) + + It("should configure managed service backends for "+profile, func() { + err := manager.ApplyProfile(profile) + Expect(err).ToNot(HaveOccurred()) + + config := manager.GetInstallConfig() + Expect(config.ManagedServiceBackends).ToNot(BeNil()) + Expect(config.ManagedServiceBackends.Postgres).ToNot(BeNil()) + }) + } + }) + + Context("profile-specific differences", func() { + It("should have different datacenter names", func() { + devManager := installer.NewInstallConfigManager() + prodManager := installer.NewInstallConfigManager() + minimalManager := installer.NewInstallConfigManager() + + devManager.ApplyProfile(installer.PROFILE_DEV) + prodManager.ApplyProfile(installer.PROFILE_PROD) + minimalManager.ApplyProfile(installer.PROFILE_MINIMAL) + + Expect(devManager.GetInstallConfig().Datacenter.Name).To(Equal("dev")) + Expect(prodManager.GetInstallConfig().Datacenter.Name).To(Equal("production")) + Expect(minimalManager.GetInstallConfig().Datacenter.Name).To(Equal("minimal")) + }) + + It("should have different PostgreSQL replica configurations", func() { + devManager := installer.NewInstallConfigManager() + prodManager := installer.NewInstallConfigManager() + + devManager.ApplyProfile(installer.PROFILE_DEV) + prodManager.ApplyProfile(installer.PROFILE_PROD) + + Expect(prodManager.GetInstallConfig().Postgres.Replica.IP).To(Equal("10.50.0.3")) + }) + + It("should have different number of Ceph hosts", func() { + devManager := installer.NewInstallConfigManager() + prodManager := installer.NewInstallConfigManager() + + devManager.ApplyProfile(installer.PROFILE_DEV) + prodManager.ApplyProfile(installer.PROFILE_PROD) + + Expect(devManager.GetInstallConfig().Ceph.Hosts).To(HaveLen(1)) + Expect(prodManager.GetInstallConfig().Ceph.Hosts).To(HaveLen(3)) + }) + + It("should have different worker node counts", func() { + devManager := installer.NewInstallConfigManager() + prodManager := installer.NewInstallConfigManager() + minimalManager := installer.NewInstallConfigManager() + + devManager.ApplyProfile(installer.PROFILE_DEV) + prodManager.ApplyProfile(installer.PROFILE_PROD) + minimalManager.ApplyProfile(installer.PROFILE_MINIMAL) + + Expect(devManager.GetInstallConfig().Kubernetes.Workers).To(HaveLen(1)) + Expect(prodManager.GetInstallConfig().Kubernetes.Workers).To(HaveLen(3)) + Expect(minimalManager.GetInstallConfig().Kubernetes.Workers).To(BeEmpty()) + }) + }) + }) + + Describe("Profile Constants", func() { + It("should have correct profile constant values", func() { + Expect(installer.PROFILE_DEV).To(Equal("dev")) + Expect(installer.PROFILE_DEVELOPMENT).To(Equal("development")) + Expect(installer.PROFILE_PROD).To(Equal("prod")) + Expect(installer.PROFILE_PRODUCTION).To(Equal("production")) + Expect(installer.PROFILE_MINIMAL).To(Equal("minimal")) + }) + }) +}) diff --git a/internal/installer/config_manager_secrets_test.go b/internal/installer/config_manager_secrets_test.go new file mode 100644 index 00000000..7b440917 --- /dev/null +++ b/internal/installer/config_manager_secrets_test.go @@ -0,0 +1,222 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer_test + +import ( + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/codesphere-cloud/oms/internal/installer" + "github.com/codesphere-cloud/oms/internal/installer/files" +) + +var _ = Describe("ConfigManagerSecrets", func() { + var ( + configManager *installer.InstallConfig + ) + + BeforeEach(func() { + configManager = &installer.InstallConfig{ + Config: &files.RootConfig{}, + } + }) + + Describe("GenerateSecrets", func() { + Context("with basic configuration", func() { + BeforeEach(func() { + configManager.Config = &files.RootConfig{ + Postgres: files.PostgresConfig{ + Primary: &files.PostgresPrimaryConfig{ + IP: "10.50.0.2", + Hostname: "pg-primary", + }, + }, + } + }) + + It("should generate secrets without error", func() { + err := configManager.GenerateSecrets() + Expect(err).ToNot(HaveOccurred()) + }) + + It("should generate domain auth ECDSA key pair", func() { + err := configManager.GenerateSecrets() + Expect(err).ToNot(HaveOccurred()) + + Expect(configManager.Config.Codesphere.DomainAuthPublicKey).ToNot(BeEmpty()) + Expect(configManager.Config.Codesphere.DomainAuthPrivateKey).ToNot(BeEmpty()) + Expect(configManager.Config.Codesphere.DomainAuthPublicKey).To(ContainSubstring("BEGIN")) + Expect(configManager.Config.Codesphere.DomainAuthPrivateKey).To(ContainSubstring("BEGIN")) + }) + + It("should generate Ceph SSH keys", func() { + err := configManager.GenerateSecrets() + Expect(err).ToNot(HaveOccurred()) + + Expect(configManager.Config.Ceph.CephAdmSSHKey.PublicKey).ToNot(BeEmpty()) + Expect(configManager.Config.Ceph.SshPrivateKey).ToNot(BeEmpty()) + }) + + It("should generate PostgreSQL secrets", func() { + err := configManager.GenerateSecrets() + Expect(err).ToNot(HaveOccurred()) + + Expect(configManager.Config.Postgres.CaCertPrivateKey).ToNot(BeEmpty()) + Expect(configManager.Config.Postgres.CACertPem).ToNot(BeEmpty()) + Expect(configManager.Config.Postgres.AdminPassword).ToNot(BeEmpty()) + Expect(configManager.Config.Postgres.ReplicaPassword).ToNot(BeEmpty()) + }) + + It("should generate unique passwords", func() { + err := configManager.GenerateSecrets() + Expect(err).ToNot(HaveOccurred()) + + adminPwd := configManager.Config.Postgres.AdminPassword + replicaPwd := configManager.Config.Postgres.ReplicaPassword + + Expect(adminPwd).ToNot(Equal(replicaPwd)) + Expect(adminPwd).To(HaveLen(32)) + Expect(replicaPwd).To(HaveLen(32)) + }) + }) + + Context("with PostgreSQL replica configuration", func() { + BeforeEach(func() { + configManager.Config = &files.RootConfig{ + Postgres: files.PostgresConfig{ + Primary: &files.PostgresPrimaryConfig{ + IP: "10.50.0.2", + Hostname: "pg-primary", + }, + Replica: &files.PostgresReplicaConfig{ + IP: "10.50.0.3", + Name: "replica1", + }, + }, + } + }) + + It("should generate replica certificates", func() { + err := configManager.GenerateSecrets() + Expect(err).ToNot(HaveOccurred()) + + Expect(configManager.Config.Postgres.Replica.PrivateKey).ToNot(BeEmpty()) + Expect(configManager.Config.Postgres.Replica.SSLConfig.ServerCertPem).ToNot(BeEmpty()) + Expect(configManager.Config.Postgres.Replica.PrivateKey).To(ContainSubstring("BEGIN RSA PRIVATE KEY")) + Expect(configManager.Config.Postgres.Replica.SSLConfig.ServerCertPem).To(ContainSubstring("BEGIN CERTIFICATE")) + }) + + It("should generate valid certificate format for primary and replica", func() { + err := configManager.GenerateSecrets() + Expect(err).ToNot(HaveOccurred()) + + // Primary server certificate + Expect(strings.HasPrefix(configManager.Config.Postgres.Primary.PrivateKey, "-----BEGIN")).To(BeTrue()) + Expect(strings.HasPrefix(configManager.Config.Postgres.Primary.SSLConfig.ServerCertPem, "-----BEGIN CERTIFICATE-----")).To(BeTrue()) + + // Replica server certificate + Expect(strings.HasPrefix(configManager.Config.Postgres.Replica.PrivateKey, "-----BEGIN")).To(BeTrue()) + Expect(strings.HasPrefix(configManager.Config.Postgres.Replica.SSLConfig.ServerCertPem, "-----BEGIN CERTIFICATE-----")).To(BeTrue()) + }) + }) + + Context("without PostgreSQL primary configuration", func() { + BeforeEach(func() { + configManager.Config = &files.RootConfig{ + Postgres: files.PostgresConfig{ + Primary: nil, + }, + } + }) + + It("should not generate PostgreSQL secrets", func() { + err := configManager.GenerateSecrets() + Expect(err).ToNot(HaveOccurred()) + + Expect(configManager.Config.Postgres.CACertPem).To(BeEmpty()) + Expect(configManager.Config.Postgres.AdminPassword).To(BeEmpty()) + }) + + It("should still generate other secrets", func() { + err := configManager.GenerateSecrets() + Expect(err).ToNot(HaveOccurred()) + + Expect(configManager.Config.Codesphere.DomainAuthPublicKey).ToNot(BeEmpty()) + Expect(configManager.Config.Cluster.IngressCAKey).ToNot(BeEmpty()) + Expect(configManager.Config.Ceph.SshPrivateKey).ToNot(BeEmpty()) + }) + }) + + Context("secret generation consistency", func() { + It("should generate different keys on each invocation", func() { + config1 := &installer.InstallConfig{ + Config: &files.RootConfig{ + Postgres: files.PostgresConfig{ + Primary: &files.PostgresPrimaryConfig{ + IP: "10.50.0.2", + Hostname: "pg-primary", + }, + }, + }, + } + config2 := &installer.InstallConfig{ + Config: &files.RootConfig{ + Postgres: files.PostgresConfig{ + Primary: &files.PostgresPrimaryConfig{ + IP: "10.50.0.2", + Hostname: "pg-primary", + }, + }, + }, + } + + err1 := config1.GenerateSecrets() + err2 := config2.GenerateSecrets() + + Expect(err1).ToNot(HaveOccurred()) + Expect(err2).ToNot(HaveOccurred()) + + Expect(config1.Config.Ceph.SshPrivateKey).ToNot(Equal(config2.Config.Ceph.SshPrivateKey)) + Expect(config1.Config.Postgres.AdminPassword).ToNot(Equal(config2.Config.Postgres.AdminPassword)) + }) + }) + + Context("CA certificate validation", func() { + BeforeEach(func() { + configManager.Config = &files.RootConfig{ + Postgres: files.PostgresConfig{ + Primary: &files.PostgresPrimaryConfig{ + IP: "10.50.0.2", + Hostname: "pg-primary", + }, + }, + } + }) + + It("should generate valid ingress CA with proper PEM format", func() { + err := configManager.GenerateSecrets() + Expect(err).ToNot(HaveOccurred()) + + // Check CA key is PEM encoded + Expect(strings.HasPrefix(configManager.Config.Cluster.IngressCAKey, "-----BEGIN")).To(BeTrue()) + Expect(strings.HasSuffix(strings.TrimSpace(configManager.Config.Cluster.IngressCAKey), "-----")).To(BeTrue()) + + // Check CA cert is PEM encoded + Expect(strings.HasPrefix(configManager.Config.Cluster.Certificates.CA.CertPem, "-----BEGIN CERTIFICATE-----")).To(BeTrue()) + Expect(strings.HasSuffix(strings.TrimSpace(configManager.Config.Cluster.Certificates.CA.CertPem), "-----END CERTIFICATE-----")).To(BeTrue()) + }) + + It("should generate valid PostgreSQL CA with proper PEM format", func() { + err := configManager.GenerateSecrets() + Expect(err).ToNot(HaveOccurred()) + + Expect(strings.HasPrefix(configManager.Config.Postgres.CaCertPrivateKey, "-----BEGIN")).To(BeTrue()) + Expect(strings.HasPrefix(configManager.Config.Postgres.CACertPem, "-----BEGIN CERTIFICATE-----")).To(BeTrue()) + }) + }) + + }) +}) diff --git a/internal/installer/config_manager_test.go b/internal/installer/config_manager_test.go new file mode 100644 index 00000000..eeb21d46 --- /dev/null +++ b/internal/installer/config_manager_test.go @@ -0,0 +1,463 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer_test + +import ( + "bytes" + "os" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/codesphere-cloud/oms/internal/installer" + "github.com/codesphere-cloud/oms/internal/installer/files" +) + +type MockFileIO struct { + files map[string][]byte + createError error + openError error + writeError error + existsResult bool + isDirResult bool + mkdirAllError error +} + +func NewMockFileIO() *MockFileIO { + return &MockFileIO{ + files: make(map[string][]byte), + } +} + +func (m *MockFileIO) Create(filename string) (*os.File, error) { + if m.createError != nil { + return nil, m.createError + } + return nil, nil +} + +func (m *MockFileIO) CreateAndWrite(filePath string, data []byte, fileType string) error { + if m.writeError != nil { + return m.writeError + } + m.files[filePath] = data + return nil +} + +func (m *MockFileIO) Open(filename string) (*os.File, error) { + if m.openError != nil { + return nil, m.openError + } + return nil, nil +} + +func (m *MockFileIO) OpenAppend(filename string) (*os.File, error) { + return nil, nil +} + +func (m *MockFileIO) Exists(path string) bool { + _, exists := m.files[path] + return exists || m.existsResult +} + +func (m *MockFileIO) IsDirectory(path string) (bool, error) { + return m.isDirResult, nil +} + +func (m *MockFileIO) MkdirAll(path string, perm os.FileMode) error { + return m.mkdirAllError +} + +func (m *MockFileIO) OpenFile(name string, flag int, perm os.FileMode) (*os.File, error) { + return nil, nil +} + +func (m *MockFileIO) WriteFile(filename string, data []byte, perm os.FileMode) error { + if m.writeError != nil { + return m.writeError + } + m.files[filename] = data + return nil +} + +func (m *MockFileIO) ReadDir(dirname string) ([]os.DirEntry, error) { + return nil, nil +} + +type MockFile struct { + *bytes.Buffer + closed bool +} + +func (m *MockFile) Close() error { + m.closed = true + return nil +} + +var _ = Describe("ConfigManager", func() { + var ( + configManager *installer.InstallConfig + ) + + BeforeEach(func() { + configManager = &installer.InstallConfig{ + Config: &files.RootConfig{}, + } + }) + + Describe("NewInstallConfigManager", func() { + It("should create a new config manager", func() { + manager := installer.NewInstallConfigManager() + Expect(manager).ToNot(BeNil()) + }) + }) + + Describe("IsValidIP", func() { + It("should validate correct IPv4 addresses", func() { + Expect(installer.IsValidIP("192.168.1.1")).To(BeTrue()) + Expect(installer.IsValidIP("10.0.0.1")).To(BeTrue()) + Expect(installer.IsValidIP("127.0.0.1")).To(BeTrue()) + }) + + It("should validate correct IPv6 addresses", func() { + Expect(installer.IsValidIP("::1")).To(BeTrue()) + Expect(installer.IsValidIP("2001:db8::1")).To(BeTrue()) + }) + + It("should reject invalid IP addresses", func() { + Expect(installer.IsValidIP("256.1.1.1")).To(BeFalse()) + Expect(installer.IsValidIP("not-an-ip")).To(BeFalse()) + Expect(installer.IsValidIP("")).To(BeFalse()) + Expect(installer.IsValidIP("192.168.1")).To(BeFalse()) + }) + }) + + Describe("ValidateInstallConfig", func() { + Context("with nil config", func() { + It("should return error", func() { + configManager.Config = nil + errors := configManager.ValidateInstallConfig() + Expect(errors).To(HaveLen(1)) + Expect(errors[0]).To(ContainSubstring("config not set")) + }) + }) + + Context("with empty config", func() { + It("should return multiple validation errors", func() { + configManager.Config = &files.RootConfig{} + errors := configManager.ValidateInstallConfig() + Expect(errors).ToNot(BeEmpty()) + }) + }) + + Context("datacenter validation", func() { + It("should require datacenter ID", func() { + configManager.Config.Datacenter.ID = 0 + errors := configManager.ValidateInstallConfig() + Expect(errors).To(ContainElement(ContainSubstring("datacenter ID is required"))) + }) + + It("should require datacenter name", func() { + configManager.Config.Datacenter.Name = "" + errors := configManager.ValidateInstallConfig() + Expect(errors).To(ContainElement(ContainSubstring("datacenter name is required"))) + }) + }) + + Context("postgres validation", func() { + It("should require postgres mode", func() { + configManager.Config.Postgres.Mode = "" + errors := configManager.ValidateInstallConfig() + Expect(errors).To(ContainElement(ContainSubstring("postgres mode is required"))) + }) + + It("should reject invalid postgres mode", func() { + configManager.Config.Postgres.Mode = "invalid" + errors := configManager.ValidateInstallConfig() + Expect(errors).To(ContainElement(ContainSubstring("invalid postgres mode"))) + }) + + Context("install mode", func() { + BeforeEach(func() { + configManager.Config.Postgres.Mode = "install" + }) + + It("should require primary configuration", func() { + configManager.Config.Postgres.Primary = nil + errors := configManager.ValidateInstallConfig() + Expect(errors).To(ContainElement(ContainSubstring("postgres primary configuration is required"))) + }) + + It("should require primary IP", func() { + configManager.Config.Postgres.Mode = "install" + configManager.Config.Postgres.Primary = &files.PostgresPrimaryConfig{ + IP: "", + Hostname: "pg-primary", + } + errors := configManager.ValidateInstallConfig() + Expect(errors).To(ContainElement(ContainSubstring("postgres primary IP is required"))) + }) + + It("should require primary hostname", func() { + configManager.Config.Postgres.Mode = "install" + configManager.Config.Postgres.Primary = &files.PostgresPrimaryConfig{ + IP: "10.50.0.2", + Hostname: "", + } + errors := configManager.ValidateInstallConfig() + Expect(errors).To(ContainElement(ContainSubstring("postgres primary hostname is required"))) + }) + }) + + Context("external mode", func() { + It("should require server address", func() { + configManager.Config.Postgres.Mode = "external" + configManager.Config.Postgres.ServerAddress = "" + errors := configManager.ValidateInstallConfig() + Expect(errors).To(ContainElement(ContainSubstring("postgres server address is required"))) + }) + }) + }) + + Context("ceph validation", func() { + It("should require at least one Ceph host", func() { + configManager.Config.Ceph.Hosts = []files.CephHost{} + errors := configManager.ValidateInstallConfig() + Expect(errors).To(ContainElement(ContainSubstring("at least one Ceph host is required"))) + }) + + It("should validate Ceph host IPs", func() { + configManager.Config.Ceph.Hosts = []files.CephHost{ + {Hostname: "ceph-0", IPAddress: "invalid-ip", IsMaster: true}, + } + errors := configManager.ValidateInstallConfig() + Expect(errors).To(ContainElement(ContainSubstring("invalid Ceph host IP"))) + }) + }) + + Context("kubernetes validation", func() { + Context("managed by Codesphere", func() { + BeforeEach(func() { + configManager.Config.Kubernetes.ManagedByCodesphere = true + }) + + It("should require at least one control plane node", func() { + configManager.Config.Kubernetes.ControlPlanes = []files.K8sNode{} + errors := configManager.ValidateInstallConfig() + Expect(errors).To(ContainElement(ContainSubstring("at least one K8s control plane node is required"))) + }) + }) + + Context("external cluster", func() { + BeforeEach(func() { + configManager.Config.Kubernetes.ManagedByCodesphere = false + }) + + It("should require pod CIDR", func() { + configManager.Config.Kubernetes.PodCIDR = "" + errors := configManager.ValidateInstallConfig() + Expect(errors).To(ContainElement(ContainSubstring("pod CIDR is required"))) + }) + + It("should require service CIDR", func() { + configManager.Config.Kubernetes.ServiceCIDR = "" + errors := configManager.ValidateInstallConfig() + Expect(errors).To(ContainElement(ContainSubstring("service CIDR is required"))) + }) + }) + }) + + Context("codesphere validation", func() { + It("should require Codesphere domain", func() { + configManager.Config.Codesphere.Domain = "" + errors := configManager.ValidateInstallConfig() + Expect(errors).To(ContainElement(ContainSubstring("Codesphere domain is required"))) + }) + }) + + Context("with valid configuration", func() { + BeforeEach(func() { + configManager.Config = &files.RootConfig{ + Datacenter: files.DatacenterConfig{ + ID: 1, + Name: "test-dc", + }, + Postgres: files.PostgresConfig{ + Mode: "install", + Primary: &files.PostgresPrimaryConfig{ + IP: "10.50.0.2", + Hostname: "pg-primary", + }, + }, + Ceph: files.CephConfig{ + Hosts: []files.CephHost{ + {Hostname: "ceph-0", IPAddress: "10.53.101.2", IsMaster: true}, + }, + }, + Kubernetes: files.KubernetesConfig{ + ManagedByCodesphere: true, + ControlPlanes: []files.K8sNode{{IPAddress: "10.50.0.2"}}, + }, + Codesphere: files.CodesphereConfig{ + Domain: "codesphere.example.com", + }, + } + }) + + It("should return no errors", func() { + errors := configManager.ValidateInstallConfig() + Expect(errors).To(BeEmpty()) + }) + }) + }) + + Describe("ValidateVault", func() { + Context("with nil vault", func() { + It("should return error", func() { + configManager.Vault = nil + errors := configManager.ValidateVault() + Expect(errors).To(HaveLen(1)) + Expect(errors[0]).To(ContainSubstring("vault not set")) + }) + }) + + Context("with empty vault", func() { + It("should return errors for missing required secrets", func() { + configManager.Vault = &files.InstallVault{ + Secrets: []files.SecretEntry{}, + } + errors := configManager.ValidateVault() + Expect(errors).ToNot(BeEmpty()) + Expect(errors).To(ContainElement(ContainSubstring("cephSshPrivateKey"))) + Expect(errors).To(ContainElement(ContainSubstring("selfSignedCaKeyPem"))) + Expect(errors).To(ContainElement(ContainSubstring("domainAuthPrivateKey"))) + Expect(errors).To(ContainElement(ContainSubstring("domainAuthPublicKey"))) + }) + }) + + Context("with valid vault", func() { + BeforeEach(func() { + configManager.Vault = &files.InstallVault{ + Secrets: []files.SecretEntry{ + {Name: "cephSshPrivateKey"}, + {Name: "selfSignedCaKeyPem"}, + {Name: "domainAuthPrivateKey"}, + {Name: "domainAuthPublicKey"}, + }, + } + }) + + It("should return no errors", func() { + errors := configManager.ValidateVault() + Expect(errors).To(BeEmpty()) + }) + }) + + Context("with partial vault", func() { + It("should return errors for missing secrets only", func() { + configManager.Vault = &files.InstallVault{ + Secrets: []files.SecretEntry{ + {Name: "cephSshPrivateKey"}, + {Name: "selfSignedCaKeyPem"}, + }, + } + errors := configManager.ValidateVault() + Expect(errors).To(HaveLen(2)) + Expect(errors).To(ContainElement(ContainSubstring("domainAuthPrivateKey"))) + Expect(errors).To(ContainElement(ContainSubstring("domainAuthPublicKey"))) + }) + }) + }) + + Describe("GetInstallConfig", func() { + It("should return the current config", func() { + testConfig := &files.RootConfig{ + Datacenter: files.DatacenterConfig{ + ID: 42, + Name: "test", + }, + } + configManager.Config = testConfig + + result := configManager.GetInstallConfig() + Expect(result).To(Equal(testConfig)) + Expect(result.Datacenter.ID).To(Equal(42)) + }) + }) + + Describe("AddConfigComments", func() { + It("should add header comments to YAML data", func() { + yamlData := []byte("datacenter:\n id: 1\n") + result := installer.AddConfigComments(yamlData) + + resultStr := string(result) + Expect(resultStr).To(ContainSubstring("Codesphere Installer Configuration")) + Expect(resultStr).To(ContainSubstring("Generated by OMS CLI")) + Expect(resultStr).To(ContainSubstring("datacenter:")) + }) + }) + + Describe("AddVaultComments", func() { + It("should add security warning header to vault YAML", func() { + yamlData := []byte("secrets:\n - name: test\n") + result := installer.AddVaultComments(yamlData) + + resultStr := string(result) + Expect(resultStr).To(ContainSubstring("Codesphere Installer Secrets")) + Expect(resultStr).To(ContainSubstring("IMPORTANT: This file contains sensitive information!")) + Expect(resultStr).To(ContainSubstring("SOPS")) + Expect(resultStr).To(ContainSubstring("Age")) + Expect(resultStr).To(ContainSubstring("secrets:")) + }) + }) + + Describe("WriteInstallConfig", func() { + It("should return error if config is nil", func() { + configManager.Config = nil + err := configManager.WriteInstallConfig("/tmp/config.yaml", false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("no configuration provided")) + }) + }) + + Describe("WriteVault", func() { + It("should return error if config is nil", func() { + configManager.Config = nil + err := configManager.WriteVault("/tmp/vault.yaml", false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("no configuration provided")) + }) + }) + + Describe("Integration Tests", func() { + Context("full configuration lifecycle", func() { + It("should apply profile, validate, and prepare for write", func() { + err := configManager.ApplyProfile("dev") + Expect(err).ToNot(HaveOccurred()) + + errors := configManager.ValidateInstallConfig() + Expect(errors).To(BeEmpty()) + + config := configManager.GetInstallConfig() + Expect(config).ToNot(BeNil()) + Expect(config.Datacenter.Name).To(Equal("dev")) + }) + + It("should generate secrets and create valid vault", func() { + err := configManager.ApplyProfile("prod") + Expect(err).ToNot(HaveOccurred()) + + err = configManager.GenerateSecrets() + Expect(err).ToNot(HaveOccurred()) + + vault := configManager.Config.ExtractVault() + configManager.Vault = vault + + errors := configManager.ValidateVault() + Expect(errors).To(BeEmpty()) + }) + }) + + }) +}) diff --git a/internal/installer/config_test.go b/internal/installer/config_test.go index 96f43df6..9e9225a1 100644 --- a/internal/installer/config_test.go +++ b/internal/installer/config_test.go @@ -71,7 +71,7 @@ codesphere: _, err := config.ParseConfigYaml(nonExistentFile) Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to parse config.yaml")) + Expect(err.Error()).To(ContainSubstring("failed to open config file")) }) }) From cbe759b897dee5536c54acec250ea161f2d51619 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Fri, 14 Nov 2025 13:30:34 +0000 Subject: [PATCH 21/24] chore(docs): Auto-update docs and licenses Signed-off-by: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> --- internal/tmpl/NOTICE | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/tmpl/NOTICE b/internal/tmpl/NOTICE index be205461..f7a2e597 100644 --- a/internal/tmpl/NOTICE +++ b/internal/tmpl/NOTICE @@ -71,9 +71,9 @@ License URL: https://github.com/inconshreveable/go-update/blob/8152e7eb6ccf/inte ---------- Module: github.com/jedib0t/go-pretty/v6 -Version: v6.6.9 +Version: v6.7.1 License: MIT -License URL: https://github.com/jedib0t/go-pretty/blob/v6.6.9/LICENSE +License URL: https://github.com/jedib0t/go-pretty/blob/v6.7.1/LICENSE ---------- Module: github.com/mattn/go-runewidth From a4d40f5f15b723a68ec7c1732db74b348edac7a3 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Fri, 14 Nov 2025 14:46:30 +0100 Subject: [PATCH 22/24] fix: handle errors --- .../init_install_config_interactive_test.go | 10 ++++--- .../config_generator_collector_test.go | 6 ++-- .../installer/config_manager_profile_test.go | 30 ++++++++++++------- 3 files changed, 30 insertions(+), 16 deletions(-) diff --git a/cli/cmd/init_install_config_interactive_test.go b/cli/cmd/init_install_config_interactive_test.go index ad0ec46c..a03fe7ff 100644 --- a/cli/cmd/init_install_config_interactive_test.go +++ b/cli/cmd/init_install_config_interactive_test.go @@ -80,13 +80,15 @@ var _ = Describe("Interactive profile usage", func() { It("should generate valid config files with profile", func() { configFile, err := os.CreateTemp("", "config-*.yaml") Expect(err).NotTo(HaveOccurred()) - defer os.Remove(configFile.Name()) - configFile.Close() + defer func() { _ = os.Remove(configFile.Name()) }() + err = configFile.Close() + Expect(err).NotTo(HaveOccurred()) vaultFile, err := os.CreateTemp("", "vault-*.yaml") Expect(err).NotTo(HaveOccurred()) - defer os.Remove(vaultFile.Name()) - vaultFile.Close() + defer func() { _ = os.Remove(vaultFile.Name()) }() + err = vaultFile.Close() + Expect(err).NotTo(HaveOccurred()) c := &InitInstallConfigCmd{ Opts: &InitInstallConfigOpts{ diff --git a/internal/installer/config_generator_collector_test.go b/internal/installer/config_generator_collector_test.go index 909223fb..be7b574a 100644 --- a/internal/installer/config_generator_collector_test.go +++ b/internal/installer/config_generator_collector_test.go @@ -89,8 +89,10 @@ var _ = Describe("ConfigGeneratorCollector", func() { Describe("Configuration Fields After Collection", func() { BeforeEach(func() { - manager.ApplyProfile("prod") - manager.CollectInteractively() + err := manager.ApplyProfile("prod") + Expect(err).ToNot(HaveOccurred()) + err = manager.CollectInteractively() + Expect(err).ToNot(HaveOccurred()) }) It("should have datacenter configuration", func() { diff --git a/internal/installer/config_manager_profile_test.go b/internal/installer/config_manager_profile_test.go index 58708c8b..205e73dc 100644 --- a/internal/installer/config_manager_profile_test.go +++ b/internal/installer/config_manager_profile_test.go @@ -264,9 +264,12 @@ var _ = Describe("ConfigManagerProfile", func() { prodManager := installer.NewInstallConfigManager() minimalManager := installer.NewInstallConfigManager() - devManager.ApplyProfile(installer.PROFILE_DEV) - prodManager.ApplyProfile(installer.PROFILE_PROD) - minimalManager.ApplyProfile(installer.PROFILE_MINIMAL) + err := devManager.ApplyProfile(installer.PROFILE_DEV) + Expect(err).ToNot(HaveOccurred()) + err = prodManager.ApplyProfile(installer.PROFILE_PROD) + Expect(err).ToNot(HaveOccurred()) + err = minimalManager.ApplyProfile(installer.PROFILE_MINIMAL) + Expect(err).ToNot(HaveOccurred()) Expect(devManager.GetInstallConfig().Datacenter.Name).To(Equal("dev")) Expect(prodManager.GetInstallConfig().Datacenter.Name).To(Equal("production")) @@ -277,8 +280,10 @@ var _ = Describe("ConfigManagerProfile", func() { devManager := installer.NewInstallConfigManager() prodManager := installer.NewInstallConfigManager() - devManager.ApplyProfile(installer.PROFILE_DEV) - prodManager.ApplyProfile(installer.PROFILE_PROD) + err := devManager.ApplyProfile(installer.PROFILE_DEV) + Expect(err).ToNot(HaveOccurred()) + err = prodManager.ApplyProfile(installer.PROFILE_PROD) + Expect(err).ToNot(HaveOccurred()) Expect(prodManager.GetInstallConfig().Postgres.Replica.IP).To(Equal("10.50.0.3")) }) @@ -287,8 +292,10 @@ var _ = Describe("ConfigManagerProfile", func() { devManager := installer.NewInstallConfigManager() prodManager := installer.NewInstallConfigManager() - devManager.ApplyProfile(installer.PROFILE_DEV) - prodManager.ApplyProfile(installer.PROFILE_PROD) + err := devManager.ApplyProfile(installer.PROFILE_DEV) + Expect(err).ToNot(HaveOccurred()) + err = prodManager.ApplyProfile(installer.PROFILE_PROD) + Expect(err).ToNot(HaveOccurred()) Expect(devManager.GetInstallConfig().Ceph.Hosts).To(HaveLen(1)) Expect(prodManager.GetInstallConfig().Ceph.Hosts).To(HaveLen(3)) @@ -299,9 +306,12 @@ var _ = Describe("ConfigManagerProfile", func() { prodManager := installer.NewInstallConfigManager() minimalManager := installer.NewInstallConfigManager() - devManager.ApplyProfile(installer.PROFILE_DEV) - prodManager.ApplyProfile(installer.PROFILE_PROD) - minimalManager.ApplyProfile(installer.PROFILE_MINIMAL) + err := devManager.ApplyProfile(installer.PROFILE_DEV) + Expect(err).ToNot(HaveOccurred()) + err = prodManager.ApplyProfile(installer.PROFILE_PROD) + Expect(err).ToNot(HaveOccurred()) + err = minimalManager.ApplyProfile(installer.PROFILE_MINIMAL) + Expect(err).ToNot(HaveOccurred()) Expect(devManager.GetInstallConfig().Kubernetes.Workers).To(HaveLen(1)) Expect(prodManager.GetInstallConfig().Kubernetes.Workers).To(HaveLen(3)) From 3a594e862e7203fc2ebd7a22261afa2a45d83782 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Mon, 17 Nov 2025 10:06:58 +0100 Subject: [PATCH 23/24] fix: interactive configuration collection with default values and improve tests --- .../installer/config_generator_collector.go | 59 ++++++++++--- .../config_generator_collector_test.go | 88 +++++++++++++------ .../installer/config_manager_profile_test.go | 67 ++++---------- .../installer/config_manager_secrets_test.go | 27 ++---- 4 files changed, 131 insertions(+), 110 deletions(-) diff --git a/internal/installer/config_generator_collector.go b/internal/installer/config_generator_collector.go index 349589da..0d13df77 100644 --- a/internal/installer/config_generator_collector.go +++ b/internal/installer/config_generator_collector.go @@ -98,8 +98,16 @@ func (g *InstallConfig) collectPostgresConfig(prompter *Prompter) { if g.Config.Postgres.Primary == nil { g.Config.Postgres.Primary = &files.PostgresPrimaryConfig{} } - g.Config.Postgres.Primary.IP = g.collectString(prompter, "Primary PostgreSQL server IP", "10.50.0.2") - g.Config.Postgres.Primary.Hostname = g.collectString(prompter, "Primary PostgreSQL hostname", "pg-primary-node") + defaultPrimaryIP := g.Config.Postgres.Primary.IP + if defaultPrimaryIP == "" { + defaultPrimaryIP = "10.50.0.2" + } + defaultPrimaryHostname := g.Config.Postgres.Primary.Hostname + if defaultPrimaryHostname == "" { + defaultPrimaryHostname = "pg-primary-node" + } + g.Config.Postgres.Primary.IP = g.collectString(prompter, "Primary PostgreSQL server IP", defaultPrimaryIP) + g.Config.Postgres.Primary.Hostname = g.collectString(prompter, "Primary PostgreSQL hostname", defaultPrimaryHostname) hasReplica := prompter.Bool("Configure PostgreSQL replica", g.Config.Postgres.Replica != nil) if hasReplica { @@ -141,16 +149,17 @@ func (g *InstallConfig) collectK8sConfig(prompter *Prompter) { g.Config.Kubernetes.ManagedByCodesphere = prompter.Bool("Use Codesphere-managed Kubernetes (k0s)", g.Config.Kubernetes.ManagedByCodesphere) if g.Config.Kubernetes.ManagedByCodesphere { - g.Config.Kubernetes.APIServerHost = g.collectString(prompter, "Kubernetes API server host (LB/DNS/IP)", "10.50.0.2") + defaultAPIServerHost := g.Config.Kubernetes.APIServerHost + if defaultAPIServerHost == "" { + defaultAPIServerHost = "10.50.0.2" + } + g.Config.Kubernetes.APIServerHost = g.collectString(prompter, "Kubernetes API server host (LB/DNS/IP)", defaultAPIServerHost) defaultControlPlanes := k8sNodesToStringSlice(g.Config.Kubernetes.ControlPlanes) if len(defaultControlPlanes) == 0 { defaultControlPlanes = []string{"10.50.0.2"} } defaultWorkers := k8sNodesToStringSlice(g.Config.Kubernetes.Workers) - if len(defaultWorkers) == 0 { - defaultWorkers = []string{"10.50.0.2", "10.50.0.3", "10.50.0.4"} - } controlPlaneIPs := g.collectStringSlice(prompter, "Control plane IP addresses (comma-separated)", defaultControlPlanes) workerIPs := g.collectStringSlice(prompter, "Worker node IP addresses (comma-separated)", defaultWorkers) @@ -217,11 +226,27 @@ func (g *InstallConfig) collectMetalLBConfig(prompter *Prompter) { func (g *InstallConfig) collectCodesphereConfig(prompter *Prompter) { fmt.Println("\n=== Codesphere Application Configuration ===") - g.Config.Codesphere.Domain = g.collectString(prompter, "Main Codesphere domain", "codesphere.yourcompany.com") - g.Config.Codesphere.WorkspaceHostingBaseDomain = g.collectString(prompter, "Workspace base domain (*.domain should point to public gateway)", "ws.yourcompany.com") + defaultDomain := g.Config.Codesphere.Domain + if defaultDomain == "" { + defaultDomain = "codesphere.yourcompany.com" + } + defaultWorkspaceDomain := g.Config.Codesphere.WorkspaceHostingBaseDomain + if defaultWorkspaceDomain == "" { + defaultWorkspaceDomain = "ws.yourcompany.com" + } + defaultCustomDomain := g.Config.Codesphere.CustomDomains.CNameBaseDomain + if defaultCustomDomain == "" { + defaultCustomDomain = "custom.yourcompany.com" + } + defaultDNSServers := g.Config.Codesphere.DNSServers + if len(defaultDNSServers) == 0 { + defaultDNSServers = []string{"1.1.1.1", "8.8.8.8"} + } + g.Config.Codesphere.Domain = g.collectString(prompter, "Main Codesphere domain", defaultDomain) + g.Config.Codesphere.WorkspaceHostingBaseDomain = g.collectString(prompter, "Workspace base domain (*.domain should point to public gateway)", defaultWorkspaceDomain) g.Config.Codesphere.PublicIP = g.collectString(prompter, "Primary public IP for workspaces", "") - g.Config.Codesphere.CustomDomains.CNameBaseDomain = g.collectString(prompter, "Custom domain CNAME base", "custom.yourcompany.com") - g.Config.Codesphere.DNSServers = g.collectStringSlice(prompter, "DNS servers (comma-separated)", []string{"1.1.1.1", "8.8.8.8"}) + g.Config.Codesphere.CustomDomains.CNameBaseDomain = g.collectString(prompter, "Custom domain CNAME base", defaultCustomDomain) + g.Config.Codesphere.DNSServers = g.collectStringSlice(prompter, "DNS servers (comma-separated)", defaultDNSServers) fmt.Println("\n=== Workspace Plans Configuration ===") @@ -246,8 +271,18 @@ func (g *InstallConfig) collectCodesphereConfig(prompter *Prompter) { workspacePlan := files.WorkspacePlan{ HostingPlanID: 1, } - workspacePlan.Name = g.collectString(prompter, "Workspace plan name", "Standard Developer") - workspacePlan.MaxReplicas = g.collectInt(prompter, "Max replicas per workspace", 3) + defaultWorkspacePlanName := "Standard Developer" + defaultMaxReplicas := 3 + if existingPlan, ok := g.Config.Codesphere.Plans.WorkspacePlans[1]; ok { + if existingPlan.Name != "" { + defaultWorkspacePlanName = existingPlan.Name + } + if existingPlan.MaxReplicas > 0 { + defaultMaxReplicas = existingPlan.MaxReplicas + } + } + workspacePlan.Name = g.collectString(prompter, "Workspace plan name", defaultWorkspacePlanName) + workspacePlan.MaxReplicas = g.collectInt(prompter, "Max replicas per workspace", defaultMaxReplicas) g.Config.Codesphere.Plans = files.PlansConfig{ HostingPlans: map[int]files.HostingPlan{ diff --git a/internal/installer/config_generator_collector_test.go b/internal/installer/config_generator_collector_test.go index be7b574a..75fbe77a 100644 --- a/internal/installer/config_generator_collector_test.go +++ b/internal/installer/config_generator_collector_test.go @@ -88,51 +88,81 @@ var _ = Describe("ConfigGeneratorCollector", func() { }) Describe("Configuration Fields After Collection", func() { - BeforeEach(func() { + It("should have common configuration properties", func() { err := manager.ApplyProfile("prod") Expect(err).ToNot(HaveOccurred()) err = manager.CollectInteractively() Expect(err).ToNot(HaveOccurred()) - }) - It("should have datacenter configuration", func() { config := manager.GetInstallConfig() - Expect(config.Datacenter.ID).ToNot(BeZero()) - Expect(config.Datacenter.Name).ToNot(BeEmpty()) - Expect(config.Datacenter.City).ToNot(BeEmpty()) - Expect(config.Datacenter.CountryCode).ToNot(BeEmpty()) - }) - It("should have PostgreSQL configuration", func() { - config := manager.GetInstallConfig() - Expect(config.Postgres.Mode).ToNot(BeEmpty()) - if config.Postgres.Mode == "install" { - Expect(config.Postgres.Primary).ToNot(BeNil()) - } + // Datacenter + Expect(config.Datacenter.ID).To(Equal(1)) + Expect(config.Datacenter.City).To(Equal("Karlsruhe")) + Expect(config.Datacenter.CountryCode).To(Equal("DE")) + + // PostgreSQL + Expect(config.Postgres.Mode).To(Equal("install")) + Expect(config.Postgres.Primary).ToNot(BeNil()) + + // Kubernetes + Expect(config.Kubernetes.ManagedByCodesphere).To(BeTrue()) + Expect(config.Kubernetes.NeedsKubeConfig).To(BeFalse()) + + // Ceph + Expect(config.Ceph.Hosts[0].IsMaster).To(BeTrue()) + + // Codesphere + Expect(config.Codesphere.Plans.HostingPlans).To(HaveLen(1)) + Expect(config.Codesphere.Plans.WorkspacePlans).To(HaveLen(1)) }) - It("should have Ceph configuration", func() { + It("should have dev profile-specific values", func() { + err := manager.ApplyProfile("dev") + Expect(err).ToNot(HaveOccurred()) + err = manager.CollectInteractively() + Expect(err).ToNot(HaveOccurred()) + config := manager.GetInstallConfig() - Expect(config.Ceph.Hosts).ToNot(BeEmpty()) - Expect(config.Ceph.NodesSubnet).ToNot(BeEmpty()) + + Expect(config.Datacenter.Name).To(Equal("dev")) + Expect(config.Postgres.Primary.IP).To(Equal("127.0.0.1")) + Expect(config.Postgres.Primary.Hostname).To(Equal("localhost")) + Expect(config.Ceph.Hosts).To(HaveLen(1)) + Expect(config.Kubernetes.Workers).To(HaveLen(1)) + Expect(config.Codesphere.Domain).To(Equal("codesphere.local")) }) - It("should have Kubernetes configuration", func() { + It("should have prod profile-specific values", func() { + err := manager.ApplyProfile("prod") + Expect(err).ToNot(HaveOccurred()) + err = manager.CollectInteractively() + Expect(err).ToNot(HaveOccurred()) + config := manager.GetInstallConfig() - if config.Kubernetes.ManagedByCodesphere { - Expect(config.Kubernetes.ControlPlanes).ToNot(BeEmpty()) - } else { - Expect(config.Kubernetes.PodCIDR).ToNot(BeEmpty()) - Expect(config.Kubernetes.ServiceCIDR).ToNot(BeEmpty()) - } + + Expect(config.Datacenter.Name).To(Equal("production")) + Expect(config.Postgres.Primary.IP).To(Equal("10.50.0.2")) + Expect(config.Postgres.Primary.Hostname).To(Equal("pg-primary")) + Expect(config.Postgres.Replica).ToNot(BeNil()) + Expect(config.Postgres.Replica.IP).To(Equal("10.50.0.3")) + Expect(config.Ceph.Hosts).To(HaveLen(3)) + Expect(config.Kubernetes.Workers).To(HaveLen(3)) + Expect(config.Codesphere.Domain).To(Equal("codesphere.yourcompany.com")) }) - It("should have Codesphere configuration", func() { + It("should have minimal profile-specific values", func() { + err := manager.ApplyProfile("minimal") + Expect(err).ToNot(HaveOccurred()) + err = manager.CollectInteractively() + Expect(err).ToNot(HaveOccurred()) + config := manager.GetInstallConfig() - Expect(config.Codesphere.Domain).ToNot(BeEmpty()) - Expect(config.Codesphere.WorkspaceHostingBaseDomain).ToNot(BeEmpty()) - Expect(config.Codesphere.Plans.HostingPlans).ToNot(BeEmpty()) - Expect(config.Codesphere.Plans.WorkspacePlans).ToNot(BeEmpty()) + + Expect(config.Datacenter.Name).To(Equal("minimal")) + Expect(config.Postgres.Primary.IP).To(Equal("127.0.0.1")) + Expect(config.Kubernetes.Workers).To(BeEmpty()) + Expect(config.Codesphere.Plans.WorkspacePlans[1].MaxReplicas).To(Equal(1)) }) }) }) diff --git a/internal/installer/config_manager_profile_test.go b/internal/installer/config_manager_profile_test.go index 205e73dc..44243b50 100644 --- a/internal/installer/config_manager_profile_test.go +++ b/internal/installer/config_manager_profile_test.go @@ -149,11 +149,12 @@ var _ = Describe("ConfigManagerProfile", func() { } for _, profile := range profiles { - It("should set common configuration for "+profile, func() { + It("should set all common properties for "+profile, func() { err := manager.ApplyProfile(profile) Expect(err).ToNot(HaveOccurred()) config := manager.GetInstallConfig() + // Datacenter config Expect(config.Datacenter.ID).To(Equal(1)) Expect(config.Datacenter.City).To(Equal("Karlsruhe")) @@ -179,15 +180,7 @@ var _ = Describe("ConfigManagerProfile", func() { Expect(config.MetalLB).ToNot(BeNil()) Expect(config.MetalLB.Enabled).To(BeFalse()) - // Secrets - Expect(config.Secrets.BaseDir).To(Equal("/root/secrets")) - }) - - It("should configure Ceph OSDs for "+profile, func() { - err := manager.ApplyProfile(profile) - Expect(err).ToNot(HaveOccurred()) - - config := manager.GetInstallConfig() + // Ceph OSDs Expect(config.Ceph.OSDs).To(HaveLen(1)) osd := config.Ceph.OSDs[0] Expect(osd.SpecID).To(Equal("default")) @@ -196,64 +189,42 @@ var _ = Describe("ConfigManagerProfile", func() { Expect(osd.DataDevices.Limit).To(Equal(1)) Expect(osd.DBDevices.Size).To(Equal("120G:150G")) Expect(osd.DBDevices.Limit).To(Equal(1)) - }) - It("should configure workspace images for "+profile, func() { - err := manager.ApplyProfile(profile) - Expect(err).ToNot(HaveOccurred()) - - config := manager.GetInstallConfig() + // Workspace images Expect(config.Codesphere.WorkspaceImages).ToNot(BeNil()) Expect(config.Codesphere.WorkspaceImages.Agent).ToNot(BeNil()) Expect(config.Codesphere.WorkspaceImages.Agent.BomRef).To(Equal("workspace-agent-24.04")) - }) - It("should configure deploy config for "+profile, func() { - err := manager.ApplyProfile(profile) - Expect(err).ToNot(HaveOccurred()) - - config := manager.GetInstallConfig() + // Deploy config images := config.Codesphere.DeployConfig.Images Expect(images).To(HaveKey("ubuntu-24.04")) ubuntu := images["ubuntu-24.04"] Expect(ubuntu.Name).To(Equal("Ubuntu 24.04")) Expect(ubuntu.SupportedUntil).To(Equal("2028-05-31")) Expect(ubuntu.Flavors).To(HaveKey("default")) - }) - It("should configure hosting plans for "+profile, func() { - err := manager.ApplyProfile(profile) - Expect(err).ToNot(HaveOccurred()) - - config := manager.GetInstallConfig() + // Hosting plans hostingPlans := config.Codesphere.Plans.HostingPlans Expect(hostingPlans).To(HaveKey(1)) - plan := hostingPlans[1] - Expect(plan.CPUTenth).To(Equal(10)) - Expect(plan.MemoryMb).To(Equal(2048)) - Expect(plan.StorageMb).To(Equal(20480)) - Expect(plan.TempStorageMb).To(Equal(1024)) - }) - - It("should configure workspace plans for "+profile, func() { - err := manager.ApplyProfile(profile) - Expect(err).ToNot(HaveOccurred()) + hostingPlan := hostingPlans[1] + Expect(hostingPlan.CPUTenth).To(Equal(10)) + Expect(hostingPlan.MemoryMb).To(Equal(2048)) + Expect(hostingPlan.StorageMb).To(Equal(20480)) + Expect(hostingPlan.TempStorageMb).To(Equal(1024)) - config := manager.GetInstallConfig() + // Workspace plans workspacePlans := config.Codesphere.Plans.WorkspacePlans Expect(workspacePlans).To(HaveKey(1)) - plan := workspacePlans[1] - Expect(plan.HostingPlanID).To(Equal(1)) - Expect(plan.OnDemand).To(BeTrue()) - }) - - It("should configure managed service backends for "+profile, func() { - err := manager.ApplyProfile(profile) - Expect(err).ToNot(HaveOccurred()) + workspacePlan := workspacePlans[1] + Expect(workspacePlan.HostingPlanID).To(Equal(1)) + Expect(workspacePlan.OnDemand).To(BeTrue()) - config := manager.GetInstallConfig() + // Managed service backends Expect(config.ManagedServiceBackends).ToNot(BeNil()) Expect(config.ManagedServiceBackends.Postgres).ToNot(BeNil()) + + // Secrets + Expect(config.Secrets.BaseDir).To(Equal("/root/secrets")) }) } }) diff --git a/internal/installer/config_manager_secrets_test.go b/internal/installer/config_manager_secrets_test.go index 7b440917..c57c7455 100644 --- a/internal/installer/config_manager_secrets_test.go +++ b/internal/installer/config_manager_secrets_test.go @@ -99,25 +99,19 @@ var _ = Describe("ConfigManagerSecrets", func() { } }) - It("should generate replica certificates", func() { - err := configManager.GenerateSecrets() - Expect(err).ToNot(HaveOccurred()) - - Expect(configManager.Config.Postgres.Replica.PrivateKey).ToNot(BeEmpty()) - Expect(configManager.Config.Postgres.Replica.SSLConfig.ServerCertPem).ToNot(BeEmpty()) - Expect(configManager.Config.Postgres.Replica.PrivateKey).To(ContainSubstring("BEGIN RSA PRIVATE KEY")) - Expect(configManager.Config.Postgres.Replica.SSLConfig.ServerCertPem).To(ContainSubstring("BEGIN CERTIFICATE")) - }) - - It("should generate valid certificate format for primary and replica", func() { + It("should generate valid certificates for primary and replica", func() { err := configManager.GenerateSecrets() Expect(err).ToNot(HaveOccurred()) // Primary server certificate + Expect(configManager.Config.Postgres.Primary.PrivateKey).ToNot(BeEmpty()) + Expect(configManager.Config.Postgres.Primary.SSLConfig.ServerCertPem).ToNot(BeEmpty()) Expect(strings.HasPrefix(configManager.Config.Postgres.Primary.PrivateKey, "-----BEGIN")).To(BeTrue()) Expect(strings.HasPrefix(configManager.Config.Postgres.Primary.SSLConfig.ServerCertPem, "-----BEGIN CERTIFICATE-----")).To(BeTrue()) // Replica server certificate + Expect(configManager.Config.Postgres.Replica.PrivateKey).ToNot(BeEmpty()) + Expect(configManager.Config.Postgres.Replica.SSLConfig.ServerCertPem).ToNot(BeEmpty()) Expect(strings.HasPrefix(configManager.Config.Postgres.Replica.PrivateKey, "-----BEGIN")).To(BeTrue()) Expect(strings.HasPrefix(configManager.Config.Postgres.Replica.SSLConfig.ServerCertPem, "-----BEGIN CERTIFICATE-----")).To(BeTrue()) }) @@ -184,7 +178,7 @@ var _ = Describe("ConfigManagerSecrets", func() { }) }) - Context("CA certificate validation", func() { + Context("cluster certificate validation", func() { BeforeEach(func() { configManager.Config = &files.RootConfig{ Postgres: files.PostgresConfig{ @@ -208,15 +202,6 @@ var _ = Describe("ConfigManagerSecrets", func() { Expect(strings.HasPrefix(configManager.Config.Cluster.Certificates.CA.CertPem, "-----BEGIN CERTIFICATE-----")).To(BeTrue()) Expect(strings.HasSuffix(strings.TrimSpace(configManager.Config.Cluster.Certificates.CA.CertPem), "-----END CERTIFICATE-----")).To(BeTrue()) }) - - It("should generate valid PostgreSQL CA with proper PEM format", func() { - err := configManager.GenerateSecrets() - Expect(err).ToNot(HaveOccurred()) - - Expect(strings.HasPrefix(configManager.Config.Postgres.CaCertPrivateKey, "-----BEGIN")).To(BeTrue()) - Expect(strings.HasPrefix(configManager.Config.Postgres.CACertPem, "-----BEGIN CERTIFICATE-----")).To(BeTrue()) - }) }) - }) }) From 6d9f4199b48cbe382ad57990a05dd5b3b911b0e1 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Mon, 17 Nov 2025 09:15:34 +0000 Subject: [PATCH 24/24] chore(docs): Auto-update docs and licenses Signed-off-by: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> --- internal/tmpl/NOTICE | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/tmpl/NOTICE b/internal/tmpl/NOTICE index f7a2e597..7ad9170d 100644 --- a/internal/tmpl/NOTICE +++ b/internal/tmpl/NOTICE @@ -17,9 +17,9 @@ License URL: https://github.com/clipperhouse/uax29/blob/v2.2.0/LICENSE ---------- Module: github.com/codesphere-cloud/cs-go/pkg/io -Version: v0.13.0 +Version: v0.14.0 License: Apache-2.0 -License URL: https://github.com/codesphere-cloud/cs-go/blob/v0.13.0/LICENSE +License URL: https://github.com/codesphere-cloud/cs-go/blob/v0.14.0/LICENSE ---------- Module: github.com/codesphere-cloud/oms/internal/tmpl