diff --git a/NOTICE b/NOTICE index 345501ed..24db1423 100644 --- a/NOTICE +++ b/NOTICE @@ -141,6 +141,12 @@ Version: v0.0.0-20230301143203-a9d515a09cc2 License: MIT License URL: https://github.com/asaskevich/govalidator/blob/a9d515a09cc2/LICENSE +---------- +Module: github.com/avast/retry-go/v5 +Version: v5.0.0 +License: MIT +License URL: https://github.com/avast/retry-go/blob/v5.0.0/LICENSE + ---------- Module: github.com/beorn7/perks/quantile Version: v1.0.1 @@ -185,21 +191,21 @@ License URL: https://github.com/cloudflare/circl/blob/v1.6.3/LICENSE ---------- Module: github.com/cloudnative-pg/barman-cloud/pkg/api -Version: v0.4.1-0.20260108104508-ced266c145f5 +Version: v0.5.0 License: Apache-2.0 -License URL: https://github.com/cloudnative-pg/barman-cloud/blob/ced266c145f5/LICENSE +License URL: https://github.com/cloudnative-pg/barman-cloud/blob/v0.5.0/LICENSE ---------- Module: github.com/cloudnative-pg/cloudnative-pg -Version: v1.28.1 +Version: v1.29.0 License: Apache-2.0 -License URL: https://github.com/cloudnative-pg/cloudnative-pg/blob/v1.28.1/LICENSE +License URL: https://github.com/cloudnative-pg/cloudnative-pg/blob/v1.29.0/LICENSE ---------- Module: github.com/cloudnative-pg/cnpg-i/pkg/identity -Version: v0.3.1 +Version: v0.5.0 License: Apache-2.0 -License URL: https://github.com/cloudnative-pg/cnpg-i/blob/v0.3.1/LICENSE +License URL: https://github.com/cloudnative-pg/cnpg-i/blob/v0.5.0/LICENSE ---------- Module: github.com/cloudnative-pg/machinery/pkg @@ -1163,15 +1169,15 @@ License URL: https://github.com/gomodules/jsonpatch/blob/v2.5.0/v2/LICENSE ---------- Module: google.golang.org/api -Version: v0.273.0 +Version: v0.273.1 License: BSD-3-Clause -License URL: https://github.com/googleapis/google-api-go-client/blob/v0.273.0/LICENSE +License URL: https://github.com/googleapis/google-api-go-client/blob/v0.273.1/LICENSE ---------- Module: google.golang.org/api/internal/third_party/uritemplates -Version: v0.273.0 +Version: v0.273.1 License: BSD-3-Clause -License URL: https://github.com/googleapis/google-api-go-client/blob/v0.273.0/internal/third_party/uritemplates/LICENSE +License URL: https://github.com/googleapis/google-api-go-client/blob/v0.273.1/internal/third_party/uritemplates/LICENSE ---------- Module: google.golang.org/genproto/googleapis @@ -1193,9 +1199,9 @@ License URL: https://github.com/googleapis/go-genproto/blob/d00831a3d3e7/googlea ---------- Module: google.golang.org/grpc -Version: v1.79.3 +Version: v1.80.0 License: Apache-2.0 -License URL: https://github.com/grpc/grpc-go/blob/v1.79.3/LICENSE +License URL: https://github.com/grpc/grpc-go/blob/v1.80.0/LICENSE ---------- Module: google.golang.org/protobuf diff --git a/cli/cmd/bootstrap_gcp.go b/cli/cmd/bootstrap_gcp.go index 5a37b801..70e3d0d1 100644 --- a/cli/cmd/bootstrap_gcp.go +++ b/cli/cmd/bootstrap_gcp.go @@ -76,8 +76,6 @@ func AddBootstrapGcpCmd(parent *cobra.Command, opts *GlobalOptions) { flags.BoolVar(&bootstrapGcpCmd.CodesphereEnv.SpotVMs, "spot-vms", false, "Use Spot VMs for Codesphere infrastructure. Falls back to standard VMs if spot capacity unavailable. Mutually exclusive with --preemptible (default: false)") flags.IntVar(&bootstrapGcpCmd.CodesphereEnv.DatacenterID, "datacenter-id", 1, "Datacenter ID (default: 1)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.CustomPgIP, "custom-pg-ip", "", "Custom PostgreSQL IP (optional)") - flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.InstallConfigPath, "install-config", "config.yaml", "Path to install config file (optional)") - flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.SecretsFilePath, "secrets-file", "prod.vault.yaml", "Path to secrets files (optional)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.Region, "region", "europe-west4", "GCP Region (default: europe-west4)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.Zone, "zone", "europe-west4-a", "GCP Zone (default: europe-west4-a)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.DNSProjectID, "dns-project-id", "", "GCP Project ID for Cloud DNS (optional)") @@ -86,12 +84,16 @@ func AddBootstrapGcpCmd(parent *cobra.Command, opts *GlobalOptions) { flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.InstallVersion, "install-version", "", "Codesphere version to install (default: none)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.InstallHash, "install-hash", "", "Codesphere package hash to install (default: none)") flags.StringArrayVarP(&bootstrapGcpCmd.CodesphereEnv.InstallSkipSteps, "install-skip-steps", "s", []string{}, "Installation steps to skip during Codesphere installation (optional)") - flags.StringVar(&bootstrapGcpCmd.InputRegistryType, "registry-type", "local-container", "Container registry type to use (options: local-container, artifact-registry) (default: artifact-registry)") - flags.BoolVar(&bootstrapGcpCmd.CodesphereEnv.WriteConfig, "write-config", true, "Write generated install config to file (default: true)") - flags.BoolVar(&bootstrapGcpCmd.SSHQuiet, "ssh-quiet", false, "Suppress SSH command output (default: false)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.RegistryUser, "registry-user", "", "Custom Registry username (only for GitHub registry type) (optional)") flags.StringArrayVar(&bootstrapGcpCmd.CodesphereEnv.Experiments, "experiments", gcp.DefaultExperiments, "Experiments to enable in Codesphere installation (optional)") flags.StringArrayVar(&bootstrapGcpCmd.CodesphereEnv.FeatureFlags, "feature-flags", []string{}, "Feature flags to enable in Codesphere installation (optional)") + flags.StringVar(&bootstrapGcpCmd.InputRegistryType, "registry-type", "local-container", "Container registry type to use (options: local-container, artifact-registry) (default: artifact-registry)") + + flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.InstallConfigPath, "install-config", "config.yaml", "Path to install config file (optional)") + flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.SecretsFilePath, "secrets-file", "prod.vault.yaml", "Path to secrets files (optional)") + flags.BoolVar(&bootstrapGcpCmd.CodesphereEnv.WriteConfig, "write-config", true, "Write generated install config to file (default: true)") + flags.BoolVar(&bootstrapGcpCmd.CodesphereEnv.RecoverConfig, "recover-config", false, "Recover previously generated install config from the jumpbox. This will overwrite the local config! (default: false)") + flags.BoolVar(&bootstrapGcpCmd.SSHQuiet, "ssh-quiet", false, "Suppress SSH command output (default: false)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.OpenBaoURI, "openbao-uri", "", "URI for OpenBao (optional)") flags.StringVar(&bootstrapGcpCmd.CodesphereEnv.OpenBaoEngine, "openbao-engine", "cs-secrets-engine", "OpenBao engine name (default: cs-secrets-engine)") diff --git a/docs/oms_beta_bootstrap-gcp.md b/docs/oms_beta_bootstrap-gcp.md index e7bc829b..bbcaf036 100644 --- a/docs/oms_beta_bootstrap-gcp.md +++ b/docs/oms_beta_bootstrap-gcp.md @@ -45,6 +45,7 @@ oms beta bootstrap-gcp [flags] --preemptible Use preemptible VMs for Codesphere infrastructure. Mutually exclusive with --spot-vms (default: false) --project-name string Unique GCP Project Name (required) --project-ttl string Time to live for the GCP project. Cleanup workflows will remove it afterwards. (default: 2 hours) (default "2h") + --recover-config Recover previously generated install config from the jumpbox. This will overwrite the local config! (default: false) --region string GCP Region (default: europe-west4) (default "europe-west4") --registry-type string Container registry type to use (options: local-container, artifact-registry) (default: artifact-registry) (default "local-container") --registry-user string Custom Registry username (only for GitHub registry type) (optional) diff --git a/internal/bootstrap/gcp/gce.go b/internal/bootstrap/gcp/gce.go index 31039168..3882d25b 100644 --- a/internal/bootstrap/gcp/gce.go +++ b/internal/bootstrap/gcp/gce.go @@ -379,3 +379,22 @@ func (b *GCPBootstrapper) ReadSSHKey(path string) (string, error) { } return key, nil } + +// GetNodeByName returns the node by the given name +// Returns an error if gce instance is not found +func (b *GCPBootstrapper) GetNodeByName(name string) (*node.Node, error) { + existingInstance, err := b.GCPClient.GetInstance(b.Env.ProjectID, b.Env.Zone, name) + if err != nil { + return nil, fmt.Errorf("failed to get instance %s: %w", name, err) + } + + existingNode := &node.Node{ + NodeClient: b.NodeClient, + FileIO: b.fw, + } + + internalIP, externalIP := ExtractInstanceIPs(existingInstance) + existingNode.UpdateNode(name, externalIP, internalIP) + + return existingNode, nil +} diff --git a/internal/bootstrap/gcp/gcp.go b/internal/bootstrap/gcp/gcp.go index 6367ae1d..9456f9d3 100644 --- a/internal/bootstrap/gcp/gcp.go +++ b/internal/bootstrap/gcp/gcp.go @@ -101,6 +101,7 @@ type CodesphereEnvironment struct { Preemptible bool `json:"preemptible"` SpotVMs bool `json:"spot_vms"` WriteConfig bool `json:"-"` + RecoverConfig bool `json:"-"` GatewayIP string `json:"gateway_ip"` PublicGatewayIP string `json:"public_gateway_ip"` RegistryType RegistryType `json:"registry_type"` @@ -405,43 +406,6 @@ func (b *GCPBootstrapper) validateGitHubParams() error { return nil } -func (b *GCPBootstrapper) EnsureInstallConfig() error { - if b.fw.Exists(b.Env.InstallConfigPath) { - err := b.icg.LoadInstallConfigFromFile(b.Env.InstallConfigPath) - if err != nil { - return fmt.Errorf("failed to load config file: %w", err) - } - - b.Env.ExistingConfigUsed = true - } else { - err := b.icg.ApplyProfile("minimal") - if err != nil { - return fmt.Errorf("failed to apply profile: %w", err) - } - } - - b.Env.InstallConfig = b.icg.GetInstallConfig() - - return nil -} - -func (b *GCPBootstrapper) EnsureSecrets() error { - if b.fw.Exists(b.Env.SecretsFilePath) { - err := b.icg.LoadVaultFromFile(b.Env.SecretsFilePath) - if err != nil { - return fmt.Errorf("failed to load vault file: %w", err) - } - err = b.icg.MergeVaultIntoConfig() - if err != nil { - return fmt.Errorf("failed to merge vault into config: %w", err) - } - } - - b.Env.Secrets = b.icg.GetVault() - - return nil -} - func (b *GCPBootstrapper) EnsureArtifactRegistry() error { repoName := "codesphere-registry" @@ -797,234 +761,6 @@ func (b *GCPBootstrapper) EnsureGitHubAccessConfigured() error { return nil } -func (b *GCPBootstrapper) UpdateInstallConfig() error { - // Update install config with necessary values - b.Env.InstallConfig.Datacenter.ID = b.Env.DatacenterID - b.Env.InstallConfig.Datacenter.City = "Karlsruhe" - b.Env.InstallConfig.Datacenter.CountryCode = "DE" - b.Env.InstallConfig.Secrets.BaseDir = b.Env.SecretsDir - if b.Env.RegistryType != RegistryTypeGitHub { - b.Env.InstallConfig.Registry.ReplaceImagesInBom = true - b.Env.InstallConfig.Registry.LoadContainerImages = true - } - - if b.Env.InstallConfig.Postgres.Primary == nil { - b.Env.InstallConfig.Postgres.Primary = &files.PostgresPrimaryConfig{ - Hostname: b.Env.PostgreSQLNode.GetName(), - } - } - b.Env.InstallConfig.Postgres.Primary.IP = b.Env.PostgreSQLNode.GetInternalIP() - - b.Env.InstallConfig.Ceph.CsiKubeletDir = "/var/lib/k0s/kubelet" - b.Env.InstallConfig.Ceph.NodesSubnet = "10.10.0.0/20" - b.Env.InstallConfig.Ceph.Hosts = []files.CephHost{ - { - Hostname: b.Env.CephNodes[0].GetName(), - IsMaster: true, - IPAddress: b.Env.CephNodes[0].GetInternalIP(), - }, - { - Hostname: b.Env.CephNodes[1].GetName(), - IPAddress: b.Env.CephNodes[1].GetInternalIP(), - }, - { - Hostname: b.Env.CephNodes[2].GetName(), - IPAddress: b.Env.CephNodes[2].GetInternalIP(), - }, - } - b.Env.InstallConfig.Ceph.OSDs = []files.CephOSD{ - { - SpecID: "default", - Placement: files.CephPlacement{ - HostPattern: "*", - }, - DataDevices: files.CephDataDevices{ - Size: "50G:", - Limit: 1, - }, - DBDevices: files.CephDBDevices{ - Size: "10G:50G", - Limit: 1, - }, - }, - } - - b.Env.InstallConfig.Kubernetes = files.KubernetesConfig{ - ManagedByCodesphere: true, - APIServerHost: b.Env.ControlPlaneNodes[0].GetInternalIP(), - ControlPlanes: []files.K8sNode{ - { - IPAddress: b.Env.ControlPlaneNodes[0].GetInternalIP(), - }, - }, - Workers: []files.K8sNode{ - { - IPAddress: b.Env.ControlPlaneNodes[0].GetInternalIP(), - }, - - { - IPAddress: b.Env.ControlPlaneNodes[1].GetInternalIP(), - }, - { - IPAddress: b.Env.ControlPlaneNodes[2].GetInternalIP(), - }, - }, - } - b.Env.InstallConfig.Cluster.Gateway.ServiceType = "LoadBalancer" - b.Env.InstallConfig.Cluster.Gateway.Annotations = map[string]string{ - "cloud.google.com/load-balancer-ipv4": b.Env.GatewayIP, - } - b.Env.InstallConfig.Cluster.PublicGateway.ServiceType = "LoadBalancer" - b.Env.InstallConfig.Cluster.PublicGateway.Annotations = map[string]string{ - "cloud.google.com/load-balancer-ipv4": b.Env.PublicGatewayIP, - } - - dnsProject := b.Env.DNSProjectID - if b.Env.DNSProjectID == "" { - dnsProject = b.Env.ProjectID - } - b.Env.InstallConfig.Cluster.Certificates.Override = map[string]interface{}{ - "issuers": map[string]interface{}{ - "letsEncryptHttp": map[string]interface{}{ - "enabled": true, - }, - "acme": map[string]interface{}{ - "dnsSolver": map[string]interface{}{ - "config": map[string]interface{}{ - "cloudDNS": map[string]interface{}{ - "project": dnsProject, - }, - }, - }, - }, - }, - } - b.Env.InstallConfig.Codesphere.CertIssuer = files.CertIssuerConfig{ - Type: "acme", - Acme: &files.ACMEConfig{ - Email: "oms-testing@" + b.Env.BaseDomain, - Server: "https://acme-v02.api.letsencrypt.org/directory", - }, - } - - b.Env.InstallConfig.Codesphere.Domain = "cs." + b.Env.BaseDomain - b.Env.InstallConfig.Codesphere.WorkspaceHostingBaseDomain = "ws." + b.Env.BaseDomain - b.Env.InstallConfig.Codesphere.PublicIP = b.Env.ControlPlaneNodes[1].GetExternalIP() - b.Env.InstallConfig.Codesphere.CustomDomains = files.CustomDomainsConfig{ - CNameBaseDomain: "ws." + b.Env.BaseDomain, - } - b.Env.InstallConfig.Codesphere.DNSServers = []string{"8.8.8.8"} - b.Env.InstallConfig.Codesphere.DeployConfig = bootstrap.DefaultCodesphereDeployConfig() - b.Env.InstallConfig.Codesphere.Plans = bootstrap.DefaultCodespherePlans() - - b.Env.InstallConfig.Codesphere.GitProviders = &files.GitProvidersConfig{} - if b.Env.GitHubAppName != "" && b.Env.GitHubAppClientID != "" && b.Env.GitHubAppClientSecret != "" { - b.Env.InstallConfig.Codesphere.GitProviders.GitHub = &files.GitProviderConfig{ - Enabled: true, - URL: "https://github.com", - API: files.APIConfig{ - BaseURL: "https://api.github.com", - }, - OAuth: files.OAuthConfig{ - Issuer: "https://github.com", - AuthorizationEndpoint: "https://github.com/login/oauth/authorize", - TokenEndpoint: "https://github.com/login/oauth/access_token", - ClientAuthMethod: "client_secret_post", - RedirectURI: "https://cs." + b.Env.BaseDomain + "/ide/auth/github/callback", - InstallationURI: "https://github.com/apps/" + b.Env.GitHubAppName + "/installations/new", - - ClientID: b.Env.GitHubAppClientID, - ClientSecret: b.Env.GitHubAppClientSecret, - }, - } - } - b.Env.InstallConfig.Codesphere.Experiments = b.Env.Experiments - b.Env.InstallConfig.Codesphere.Features = b.Env.FeatureFlags - - if !b.Env.ExistingConfigUsed { - err := b.icg.GenerateSecrets() - if err != nil { - return fmt.Errorf("failed to generate secrets: %w", err) - } - } else { - var err error - b.Env.InstallConfig.Postgres.Primary.PrivateKey, b.Env.InstallConfig.Postgres.Primary.SSLConfig.ServerCertPem, err = installer.GenerateServerCertificate( - b.Env.InstallConfig.Postgres.CaCertPrivateKey, - b.Env.InstallConfig.Postgres.CACertPem, - b.Env.InstallConfig.Postgres.Primary.Hostname, - []string{b.Env.InstallConfig.Postgres.Primary.IP}) - if err != nil { - return fmt.Errorf("failed to generate primary server certificate: %w", err) - } - if b.Env.InstallConfig.Postgres.Replica != nil { - b.Env.InstallConfig.Postgres.ReplicaPrivateKey, b.Env.InstallConfig.Postgres.Replica.SSLConfig.ServerCertPem, err = installer.GenerateServerCertificate( - b.Env.InstallConfig.Postgres.CaCertPrivateKey, - b.Env.InstallConfig.Postgres.CACertPem, - b.Env.InstallConfig.Postgres.Replica.Name, - []string{b.Env.InstallConfig.Postgres.Replica.IP}) - if err != nil { - return fmt.Errorf("failed to generate replica server certificate: %w", err) - } - } - } - - if b.Env.OpenBaoURI != "" { - b.Env.InstallConfig.Codesphere.OpenBao = &files.OpenBaoConfig{ - Engine: b.Env.OpenBaoEngine, - URI: b.Env.OpenBaoURI, - User: b.Env.OpenBaoUser, - Password: b.Env.OpenBaoPassword, - } - } - - if err := b.icg.WriteInstallConfig(b.Env.InstallConfigPath, true); err != nil { - return fmt.Errorf("failed to write config file: %w", err) - } - - if err := b.icg.WriteVault(b.Env.SecretsFilePath, true); err != nil { - return fmt.Errorf("failed to write vault file: %w", err) - } - - err := b.Env.Jumpbox.NodeClient.CopyFile(b.Env.Jumpbox, b.Env.InstallConfigPath, "/etc/codesphere/config.yaml") - if err != nil { - return fmt.Errorf("failed to copy install config to jumpbox: %w", err) - } - - err = b.Env.Jumpbox.NodeClient.CopyFile(b.Env.Jumpbox, b.Env.SecretsFilePath, b.Env.SecretsDir+"/prod.vault.yaml") - if err != nil { - return fmt.Errorf("failed to copy secrets file to jumpbox: %w", err) - } - return nil -} - -func (b *GCPBootstrapper) EnsureAgeKey() error { - hasKey := b.Env.Jumpbox.NodeClient.HasFile(b.Env.Jumpbox, b.Env.SecretsDir+"/age_key.txt") - if hasKey { - return nil - } - - err := b.Env.Jumpbox.RunSSHCommand("root", fmt.Sprintf("mkdir -p %s; age-keygen -o %s/age_key.txt", b.Env.SecretsDir, b.Env.SecretsDir)) - if err != nil { - return fmt.Errorf("failed to generate age key on jumpbox: %w", err) - } - - return nil -} - -func (b *GCPBootstrapper) EncryptVault() error { - err := b.Env.Jumpbox.RunSSHCommand("root", "cp "+b.Env.SecretsDir+"/prod.vault.yaml{,.bak}") - if err != nil { - return fmt.Errorf("failed backup vault on jumpbox: %w", err) - } - - err = b.Env.Jumpbox.RunSSHCommand("root", "sops --encrypt --in-place --age $(age-keygen -y "+b.Env.SecretsDir+"/age_key.txt) "+b.Env.SecretsDir+"/prod.vault.yaml") - if err != nil { - return fmt.Errorf("failed to encrypt vault on jumpbox: %w", err) - } - - return nil -} - func (b *GCPBootstrapper) EnsureDNSRecords() error { gcpProject := b.Env.DNSProjectID if b.Env.DNSProjectID == "" { diff --git a/internal/bootstrap/gcp/install_config.go b/internal/bootstrap/gcp/install_config.go new file mode 100644 index 00000000..288071e9 --- /dev/null +++ b/internal/bootstrap/gcp/install_config.go @@ -0,0 +1,320 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package gcp + +import ( + "fmt" + + "github.com/codesphere-cloud/oms/internal/bootstrap" + "github.com/codesphere-cloud/oms/internal/installer" + "github.com/codesphere-cloud/oms/internal/installer/files" +) + +const ( + remoteInstallConfigPath string = "/etc/codesphere/config.yaml" +) + +// EnsureInstallConfig uses the local config or recovers it from an existing jumpbox if desired. +// Else it applies the minimal profile to a new config. +func (b *GCPBootstrapper) EnsureInstallConfig() error { + // recovery will overwrite local config or create a new file + if b.Env.RecoverConfig { + err := b.recoverConfig() + if err != nil { + return fmt.Errorf("failed to recover config: %w", err) + } + } + + if b.fw.Exists(b.Env.InstallConfigPath) { + err := b.icg.LoadInstallConfigFromFile(b.Env.InstallConfigPath) + if err != nil { + return fmt.Errorf("failed to load config file: %w", err) + } + + b.Env.ExistingConfigUsed = true + } else { + err := b.icg.ApplyProfile("minimal") + if err != nil { + return fmt.Errorf("failed to apply profile: %w", err) + } + } + + b.Env.InstallConfig = b.icg.GetInstallConfig() + + return nil +} + +// recoverConfig downloads the config and secrets from the jumpbox if it exists. +// Since recovery is done when the project or VMs are not ensured, we need to search for the jumpbox IP first. +// Returns an error if project or jumpbox does not exist or downloading fails. +func (b *GCPBootstrapper) recoverConfig() error { + existingProject, err := b.GCPClient.GetProjectByName(b.Env.FolderID, b.Env.ProjectName) + if err != nil { + return fmt.Errorf("failed to find gcp project for config recovery: %w", err) + } + b.Env.ProjectID = existingProject.ProjectId + + jumpbox, err := b.GetNodeByName("jumpbox") + if err != nil { + return fmt.Errorf("failed to find jumpbox node for config recovery: %w", err) + } + + err = jumpbox.NodeClient.DownloadFile(jumpbox, remoteInstallConfigPath, b.Env.InstallConfigPath) + if err != nil { + return fmt.Errorf("failed to recover install config from jumpbox: %w", err) + } + + err = jumpbox.NodeClient.DownloadFile(jumpbox, b.Env.SecretsDir+"/prod.vault.yaml", b.Env.SecretsFilePath) + if err != nil { + return fmt.Errorf("failed to recover secrets file from jumpbox: %w", err) + } + + return nil +} + +func (b *GCPBootstrapper) UpdateInstallConfig() error { + // Update install config with necessary values + b.Env.InstallConfig.Datacenter.ID = b.Env.DatacenterID + b.Env.InstallConfig.Datacenter.City = "Karlsruhe" + b.Env.InstallConfig.Datacenter.CountryCode = "DE" + b.Env.InstallConfig.Secrets.BaseDir = b.Env.SecretsDir + if b.Env.RegistryType != RegistryTypeGitHub { + b.Env.InstallConfig.Registry.ReplaceImagesInBom = true + b.Env.InstallConfig.Registry.LoadContainerImages = true + } + + if b.Env.InstallConfig.Postgres.Primary == nil { + b.Env.InstallConfig.Postgres.Primary = &files.PostgresPrimaryConfig{ + Hostname: b.Env.PostgreSQLNode.GetName(), + } + } + b.Env.InstallConfig.Postgres.Primary.IP = b.Env.PostgreSQLNode.GetInternalIP() + + b.Env.InstallConfig.Ceph.CsiKubeletDir = "/var/lib/k0s/kubelet" + b.Env.InstallConfig.Ceph.NodesSubnet = "10.10.0.0/20" + b.Env.InstallConfig.Ceph.Hosts = []files.CephHost{ + { + Hostname: b.Env.CephNodes[0].GetName(), + IsMaster: true, + IPAddress: b.Env.CephNodes[0].GetInternalIP(), + }, + { + Hostname: b.Env.CephNodes[1].GetName(), + IPAddress: b.Env.CephNodes[1].GetInternalIP(), + }, + { + Hostname: b.Env.CephNodes[2].GetName(), + IPAddress: b.Env.CephNodes[2].GetInternalIP(), + }, + } + b.Env.InstallConfig.Ceph.OSDs = []files.CephOSD{ + { + SpecID: "default", + Placement: files.CephPlacement{ + HostPattern: "*", + }, + DataDevices: files.CephDataDevices{ + Size: "50G:", + Limit: 1, + }, + DBDevices: files.CephDBDevices{ + Size: "10G:50G", + Limit: 1, + }, + }, + } + + b.Env.InstallConfig.Kubernetes = files.KubernetesConfig{ + ManagedByCodesphere: true, + APIServerHost: b.Env.ControlPlaneNodes[0].GetInternalIP(), + ControlPlanes: []files.K8sNode{ + { + IPAddress: b.Env.ControlPlaneNodes[0].GetInternalIP(), + }, + }, + Workers: []files.K8sNode{ + { + IPAddress: b.Env.ControlPlaneNodes[0].GetInternalIP(), + }, + + { + IPAddress: b.Env.ControlPlaneNodes[1].GetInternalIP(), + }, + { + IPAddress: b.Env.ControlPlaneNodes[2].GetInternalIP(), + }, + }, + } + b.Env.InstallConfig.Cluster.Gateway.ServiceType = "LoadBalancer" + b.Env.InstallConfig.Cluster.Gateway.Annotations = map[string]string{ + "cloud.google.com/load-balancer-ipv4": b.Env.GatewayIP, + } + b.Env.InstallConfig.Cluster.PublicGateway.ServiceType = "LoadBalancer" + b.Env.InstallConfig.Cluster.PublicGateway.Annotations = map[string]string{ + "cloud.google.com/load-balancer-ipv4": b.Env.PublicGatewayIP, + } + + dnsProject := b.Env.DNSProjectID + if b.Env.DNSProjectID == "" { + dnsProject = b.Env.ProjectID + } + b.Env.InstallConfig.Cluster.Certificates.Override = map[string]interface{}{ + "issuers": map[string]interface{}{ + "letsEncryptHttp": map[string]interface{}{ + "enabled": true, + }, + "acme": map[string]interface{}{ + "dnsSolver": map[string]interface{}{ + "config": map[string]interface{}{ + "cloudDNS": map[string]interface{}{ + "project": dnsProject, + }, + }, + }, + }, + }, + } + b.Env.InstallConfig.Codesphere.CertIssuer = files.CertIssuerConfig{ + Type: "acme", + Acme: &files.ACMEConfig{ + Email: "oms-testing@" + b.Env.BaseDomain, + Server: "https://acme-v02.api.letsencrypt.org/directory", + }, + } + + b.Env.InstallConfig.Codesphere.Domain = "cs." + b.Env.BaseDomain + b.Env.InstallConfig.Codesphere.WorkspaceHostingBaseDomain = "ws." + b.Env.BaseDomain + b.Env.InstallConfig.Codesphere.PublicIP = b.Env.ControlPlaneNodes[1].GetExternalIP() + b.Env.InstallConfig.Codesphere.CustomDomains = files.CustomDomainsConfig{ + CNameBaseDomain: "ws." + b.Env.BaseDomain, + } + b.Env.InstallConfig.Codesphere.DNSServers = []string{"8.8.8.8"} + b.Env.InstallConfig.Codesphere.DeployConfig = bootstrap.DefaultCodesphereDeployConfig() + b.Env.InstallConfig.Codesphere.Plans = bootstrap.DefaultCodespherePlans() + + b.Env.InstallConfig.Codesphere.GitProviders = &files.GitProvidersConfig{} + if b.Env.GitHubAppName != "" && b.Env.GitHubAppClientID != "" && b.Env.GitHubAppClientSecret != "" { + b.Env.InstallConfig.Codesphere.GitProviders.GitHub = &files.GitProviderConfig{ + Enabled: true, + URL: "https://github.com", + API: files.APIConfig{ + BaseURL: "https://api.github.com", + }, + OAuth: files.OAuthConfig{ + Issuer: "https://github.com", + AuthorizationEndpoint: "https://github.com/login/oauth/authorize", + TokenEndpoint: "https://github.com/login/oauth/access_token", + ClientAuthMethod: "client_secret_post", + RedirectURI: "https://cs." + b.Env.BaseDomain + "/ide/auth/github/callback", + InstallationURI: "https://github.com/apps/" + b.Env.GitHubAppName + "/installations/new", + + ClientID: b.Env.GitHubAppClientID, + ClientSecret: b.Env.GitHubAppClientSecret, + }, + } + } + b.Env.InstallConfig.Codesphere.Experiments = b.Env.Experiments + b.Env.InstallConfig.Codesphere.Features = b.Env.FeatureFlags + + if !b.Env.ExistingConfigUsed { + err := b.icg.GenerateSecrets() + if err != nil { + return fmt.Errorf("failed to generate secrets: %w", err) + } + } else { + var err error + b.Env.InstallConfig.Postgres.Primary.PrivateKey, b.Env.InstallConfig.Postgres.Primary.SSLConfig.ServerCertPem, err = installer.GenerateServerCertificate( + b.Env.InstallConfig.Postgres.CaCertPrivateKey, + b.Env.InstallConfig.Postgres.CACertPem, + b.Env.InstallConfig.Postgres.Primary.Hostname, + []string{b.Env.InstallConfig.Postgres.Primary.IP}) + if err != nil { + return fmt.Errorf("failed to generate primary server certificate: %w", err) + } + if b.Env.InstallConfig.Postgres.Replica != nil { + b.Env.InstallConfig.Postgres.ReplicaPrivateKey, b.Env.InstallConfig.Postgres.Replica.SSLConfig.ServerCertPem, err = installer.GenerateServerCertificate( + b.Env.InstallConfig.Postgres.CaCertPrivateKey, + b.Env.InstallConfig.Postgres.CACertPem, + b.Env.InstallConfig.Postgres.Replica.Name, + []string{b.Env.InstallConfig.Postgres.Replica.IP}) + if err != nil { + return fmt.Errorf("failed to generate replica server certificate: %w", err) + } + } + } + + if b.Env.OpenBaoURI != "" { + b.Env.InstallConfig.Codesphere.OpenBao = &files.OpenBaoConfig{ + Engine: b.Env.OpenBaoEngine, + URI: b.Env.OpenBaoURI, + User: b.Env.OpenBaoUser, + Password: b.Env.OpenBaoPassword, + } + } + + if err := b.icg.WriteInstallConfig(b.Env.InstallConfigPath, true); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + + if err := b.icg.WriteVault(b.Env.SecretsFilePath, true); err != nil { + return fmt.Errorf("failed to write vault file: %w", err) + } + + err := b.Env.Jumpbox.NodeClient.CopyFile(b.Env.Jumpbox, b.Env.InstallConfigPath, remoteInstallConfigPath) + if err != nil { + return fmt.Errorf("failed to copy install config to jumpbox: %w", err) + } + + err = b.Env.Jumpbox.NodeClient.CopyFile(b.Env.Jumpbox, b.Env.SecretsFilePath, b.Env.SecretsDir+"/prod.vault.yaml") + if err != nil { + return fmt.Errorf("failed to copy secrets file to jumpbox: %w", err) + } + + return nil +} + +func (b *GCPBootstrapper) EnsureAgeKey() error { + hasKey := b.Env.Jumpbox.NodeClient.HasFile(b.Env.Jumpbox, b.Env.SecretsDir+"/age_key.txt") + if hasKey { + return nil + } + + err := b.Env.Jumpbox.RunSSHCommand("root", fmt.Sprintf("mkdir -p %s; age-keygen -o %s/age_key.txt", b.Env.SecretsDir, b.Env.SecretsDir)) + if err != nil { + return fmt.Errorf("failed to generate age key on jumpbox: %w", err) + } + + return nil +} + +func (b *GCPBootstrapper) EnsureSecrets() error { + if b.fw.Exists(b.Env.SecretsFilePath) { + err := b.icg.LoadVaultFromFile(b.Env.SecretsFilePath) + if err != nil { + return fmt.Errorf("failed to load vault file: %w", err) + } + err = b.icg.MergeVaultIntoConfig() + if err != nil { + return fmt.Errorf("failed to merge vault into config: %w", err) + } + } + + b.Env.Secrets = b.icg.GetVault() + + return nil +} + +func (b *GCPBootstrapper) EncryptVault() error { + err := b.Env.Jumpbox.RunSSHCommand("root", "cp "+b.Env.SecretsDir+"/prod.vault.yaml{,.bak}") + if err != nil { + return fmt.Errorf("failed backup vault on jumpbox: %w", err) + } + + err = b.Env.Jumpbox.RunSSHCommand("root", "sops --encrypt --in-place --age $(age-keygen -y "+b.Env.SecretsDir+"/age_key.txt) "+b.Env.SecretsDir+"/prod.vault.yaml") + if err != nil { + return fmt.Errorf("failed to encrypt vault on jumpbox: %w", err) + } + + return nil +} diff --git a/internal/bootstrap/gcp/install_config_test.go b/internal/bootstrap/gcp/install_config_test.go new file mode 100644 index 00000000..5ea84d43 --- /dev/null +++ b/internal/bootstrap/gcp/install_config_test.go @@ -0,0 +1,520 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package gcp_test + +import ( + "context" + "fmt" + "strings" + + "cloud.google.com/go/resourcemanager/apiv3/resourcemanagerpb" + "github.com/codesphere-cloud/oms/internal/bootstrap" + "github.com/codesphere-cloud/oms/internal/bootstrap/gcp" + "github.com/codesphere-cloud/oms/internal/env" + "github.com/codesphere-cloud/oms/internal/github" + "github.com/codesphere-cloud/oms/internal/installer" + "github.com/codesphere-cloud/oms/internal/installer/files" + "github.com/codesphere-cloud/oms/internal/installer/node" + "github.com/codesphere-cloud/oms/internal/portal" + "github.com/codesphere-cloud/oms/internal/util" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/stretchr/testify/mock" + "google.golang.org/grpc/codes" + grpcstatus "google.golang.org/grpc/status" +) + +var _ = Describe("Installconfig & Secrets", func() { + var ( + nodeClient *node.MockNodeClient + csEnv *gcp.CodesphereEnvironment + ctx context.Context + e env.Env + + icg *installer.MockInstallConfigManager + gc *gcp.MockGCPClientManager + fw *util.MockFileIO + stlog *bootstrap.StepLogger + mockPortalClient *portal.MockPortal + mockGitHubClient *github.MockGitHubClient + + bs *gcp.GCPBootstrapper + ) + + JustBeforeEach(func() { + var err error + bs, err = gcp.NewGCPBootstrapper( + ctx, + e, + stlog, + csEnv, + icg, + gc, + fw, + nodeClient, + mockPortalClient, + util.NewFakeTime(), + mockGitHubClient, + ) + Expect(err).NotTo(HaveOccurred()) + }) + + BeforeEach(func() { + nodeClient = node.NewMockNodeClient(GinkgoT()) + ctx = context.Background() + e = env.NewEnv() + icg = installer.NewMockInstallConfigManager(GinkgoT()) + gc = gcp.NewMockGCPClientManager(GinkgoT()) + fw = util.NewMockFileIO(GinkgoT()) + mockPortalClient = portal.NewMockPortal(GinkgoT()) + mockGitHubClient = github.NewMockGitHubClient(GinkgoT()) + stlog = bootstrap.NewStepLogger(false) + + csEnv = &gcp.CodesphereEnvironment{ + GitHubAppName: "fake-app", + GitHubAppClientID: "fake-client-id", + GitHubAppClientSecret: "fake-secret", + InstallConfigPath: "fake-config-file", + SecretsFilePath: "fake-secret", + RecoverConfig: false, + ProjectName: "test-project", + ProjectTTL: "1h", + SecretsDir: "/etc/codesphere/secrets", + BillingAccount: "test-billing-account", + Region: "us-central1", + Zone: "us-central1-a", + DatacenterID: 1, + BaseDomain: "example.com", + DNSProjectID: "dns-project", + DNSZoneName: "test-zone", + SSHPublicKeyPath: "key.pub", + ProjectID: "pid", + Experiments: gcp.DefaultExperiments, + FeatureFlags: []string{}, + InstallConfig: &files.RootConfig{ + Registry: &files.RegistryConfig{}, + Postgres: files.PostgresConfig{ + Primary: &files.PostgresPrimaryConfig{}, + }, + Cluster: files.ClusterConfig{}, + }, + Jumpbox: fakeNode("jumpbox", nodeClient), + PostgreSQLNode: fakeNode("postgres", nodeClient), + ControlPlaneNodes: []*node.Node{fakeNode("k0s-1", nodeClient), fakeNode("k0s-2", nodeClient), fakeNode("k0s-3", nodeClient)}, + CephNodes: []*node.Node{fakeNode("ceph-1", nodeClient), fakeNode("ceph-2", nodeClient), fakeNode("ceph-3", nodeClient)}, + } + }) + + Describe("EnsureInstallConfig", func() { + Describe("Valid EnsureInstallConfig", func() { + BeforeEach(func() { + }) + It("uses existing when config file exists", func() { + fw.EXPECT().Exists(csEnv.InstallConfigPath).Return(true) + icg.EXPECT().LoadInstallConfigFromFile(csEnv.InstallConfigPath).Return(nil) + icg.EXPECT().GetInstallConfig().Return(&files.RootConfig{}) + + err := bs.EnsureInstallConfig() + Expect(err).NotTo(HaveOccurred()) + }) + + It("creates install config when missing", func() { + fw.EXPECT().Exists(csEnv.InstallConfigPath).Return(false) + icg.EXPECT().ApplyProfile("minimal").Return(nil) + icg.EXPECT().GetInstallConfig().Return(&files.RootConfig{}) + + err := bs.EnsureInstallConfig() + Expect(err).NotTo(HaveOccurred()) + Expect(bs.Env.InstallConfig).NotTo(BeNil()) + }) + + Describe("Config Recovery from Jumpbox", func() { + JustBeforeEach(func() { + csEnv.RecoverConfig = true + gc.EXPECT().GetProjectByName(mock.Anything, mock.Anything).Return(&resourcemanagerpb.Project{ProjectId: csEnv.ProjectID, Name: "existing-proj"}, nil) + + runningResp := makeRunningInstance("10.0.0.x", "1.2.3.x") + gc.EXPECT().GetInstance(csEnv.ProjectID, csEnv.Zone, "jumpbox").Return(runningResp, nil) + + nodeClient.EXPECT().DownloadFile(mock.Anything, mock.Anything, mock.Anything).Return(nil) + }) + + FIt("overwrites an existing config", func() { + fw.EXPECT().Exists(csEnv.InstallConfigPath).Return(true) + icg.EXPECT().LoadInstallConfigFromFile(csEnv.InstallConfigPath).Return(nil) + icg.EXPECT().GetInstallConfig().Return(&files.RootConfig{}) + + err := bs.EnsureInstallConfig() + Expect(err).ToNot(HaveOccurred()) + }) + }) + }) + + Describe("Invalid cases", func() { + It("returns error when config file exists but fails to load", func() { + fw.EXPECT().Exists(csEnv.InstallConfigPath).Return(true) + icg.EXPECT().LoadInstallConfigFromFile(csEnv.InstallConfigPath).Return(fmt.Errorf("bad format")) + + err := bs.EnsureInstallConfig() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to load config file")) + Expect(err.Error()).To(ContainSubstring("bad format")) + }) + + It("returns error when config file missing and applying profile fails", func() { + fw.EXPECT().Exists(csEnv.InstallConfigPath).Return(false) + icg.EXPECT().ApplyProfile("minimal").Return(fmt.Errorf("profile error")) + + err := bs.EnsureInstallConfig() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to apply profile")) + Expect(err.Error()).To(ContainSubstring("profile error")) + }) + + Describe("returns an error when config recovery fails", func() { + JustBeforeEach(func() { + csEnv.RecoverConfig = true + }) + + FIt("return an error when project for recovery is not found", func() { + gc.EXPECT().GetProjectByName(mock.Anything, mock.Anything).Return(nil, fmt.Errorf("project not found")) + + err := bs.EnsureInstallConfig() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to find gcp project for config recovery")) + Expect(err.Error()).To(ContainSubstring("project not found")) + }) + + FIt("return an error when jumpbox for recovery is not found", func() { + gc.EXPECT().GetProjectByName(mock.Anything, mock.Anything).Return(&resourcemanagerpb.Project{ProjectId: csEnv.ProjectID, Name: "existing-proj"}, nil) + gc.EXPECT().GetInstance(csEnv.ProjectID, csEnv.Zone, "jumpbox").Return(nil, grpcstatus.Errorf(codes.NotFound, "not found")) + + err := bs.EnsureInstallConfig() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to find jumpbox node for config recovery")) + Expect(err.Error()).To(ContainSubstring("not found")) + }) + + FIt("return an error when config download fails from jumpbox for recovery", func() { + gc.EXPECT().GetProjectByName(mock.Anything, mock.Anything).Return(&resourcemanagerpb.Project{ProjectId: csEnv.ProjectID, Name: "existing-proj"}, nil) + + runningResp := makeRunningInstance("10.0.0.x", "1.2.3.x") + gc.EXPECT().GetInstance(csEnv.ProjectID, csEnv.Zone, "jumpbox").Return(runningResp, nil) + + nodeClient.EXPECT().DownloadFile(mock.Anything, mock.Anything, csEnv.InstallConfigPath).Return(fmt.Errorf("failed")) + + err := bs.EnsureInstallConfig() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to recover install config from jumpbox")) + }) + + FIt("return an error when secrets download fails from jumpbox for recovery", func() { + gc.EXPECT().GetProjectByName(mock.Anything, mock.Anything).Return(&resourcemanagerpb.Project{ProjectId: csEnv.ProjectID, Name: "existing-proj"}, nil) + + runningResp := makeRunningInstance("10.0.0.x", "1.2.3.x") + gc.EXPECT().GetInstance(csEnv.ProjectID, csEnv.Zone, "jumpbox").Return(runningResp, nil) + + nodeClient.EXPECT().DownloadFile(mock.Anything, mock.Anything, csEnv.InstallConfigPath).Return(nil) + nodeClient.EXPECT().DownloadFile(mock.Anything, mock.Anything, csEnv.SecretsFilePath).Return(fmt.Errorf("failed")) + + err := bs.EnsureInstallConfig() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to recover secrets file from jumpbox")) + }) + }) + + }) + }) + + Describe("EnsureSecrets", func() { + Describe("Valid EnsureSecrets", func() { + It("loads existing secrets file", func() { + fw.EXPECT().Exists(csEnv.SecretsFilePath).Return(true) + icg.EXPECT().LoadVaultFromFile(csEnv.SecretsFilePath).Return(nil) + icg.EXPECT().MergeVaultIntoConfig().Return(nil) + icg.EXPECT().GetVault().Return(&files.InstallVault{}) + + err := bs.EnsureSecrets() + Expect(err).NotTo(HaveOccurred()) + }) + + It("skips when secrets file missing", func() { + fw.EXPECT().Exists(csEnv.SecretsFilePath).Return(false) + icg.EXPECT().GetVault().Return(&files.InstallVault{}) + + err := bs.EnsureSecrets() + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Describe("Invalid cases", func() { + It("returns error when secrets file load fails", func() { + fw.EXPECT().Exists(csEnv.SecretsFilePath).Return(true) + icg.EXPECT().LoadVaultFromFile(csEnv.SecretsFilePath).Return(fmt.Errorf("load error")) + + err := bs.EnsureSecrets() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to load vault file")) + Expect(err.Error()).To(ContainSubstring("load error")) + }) + + It("returns error when merge fails", func() { + fw.EXPECT().Exists(csEnv.SecretsFilePath).Return(true) + icg.EXPECT().LoadVaultFromFile(csEnv.SecretsFilePath).Return(nil) + icg.EXPECT().MergeVaultIntoConfig().Return(fmt.Errorf("merge error")) + + err := bs.EnsureSecrets() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to merge vault into config")) + Expect(err.Error()).To(ContainSubstring("merge error")) + }) + }) + }) + + Describe("UpdateInstallConfig", func() { + BeforeEach(func() { + csEnv.GitHubAppName = "fake-app-name" + }) + Describe("Valid UpdateInstallConfig", func() { + It("updates config and writes files", func() { + icg.EXPECT().GenerateSecrets().Return(nil) + icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) + + nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() + + err := bs.UpdateInstallConfig() + Expect(err).NotTo(HaveOccurred()) + + Expect(bs.Env.InstallConfig.Datacenter.ID).To(Equal(1)) + Expect(bs.Env.InstallConfig.Codesphere.Domain).To(Equal("cs.example.com")) + Expect(bs.Env.InstallConfig.Codesphere.Features).To(Equal([]string{})) + Expect(bs.Env.InstallConfig.Codesphere.Experiments).To(Equal(gcp.DefaultExperiments)) + + expectedInstallURI := "https://github.com/apps/" + bs.Env.GitHubAppName + "/installations/new" + Expect(bs.Env.InstallConfig.Codesphere.GitProviders.GitHub.OAuth.InstallationURI).To(Equal(expectedInstallURI)) + expectedRedirectURI := "https://cs." + bs.Env.BaseDomain + "/ide/auth/github/callback" + Expect(bs.Env.InstallConfig.Codesphere.GitProviders.GitHub.OAuth.RedirectURI).To(Equal(expectedRedirectURI)) + Expect(bs.Env.InstallConfig.Codesphere.GitProviders.GitHub.OAuth.ClientAuthMethod).To(Equal("client_secret_post")) + + issuers := bs.Env.InstallConfig.Cluster.Certificates.Override["issuers"].(map[string]interface{}) + httpIssuer := issuers["letsEncryptHttp"].(map[string]interface{}) + Expect(httpIssuer["enabled"]).To(Equal(true)) + + acme := issuers["acme"].(map[string]interface{}) + dnsIssuer := acme["dnsSolver"].(map[string]interface{}) + dnsConfig := dnsIssuer["config"].(map[string]interface{}) + cloudDns := dnsConfig["cloudDNS"].(map[string]interface{}) + Expect(cloudDns["project"]).To(Equal(bs.Env.DNSProjectID)) + + Expect(bs.Env.InstallConfig.Codesphere.OpenBao).To(BeNil()) + }) + Context("When Experiments are set in CodesphereEnvironment", func() { + BeforeEach(func() { + csEnv.Experiments = []string{"fake-exp1", "fake-exp2"} + }) + It("uses those experiments instead of defaults", func() { + icg.EXPECT().GenerateSecrets().Return(nil) + icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) + + nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() + + err := bs.UpdateInstallConfig() + Expect(err).NotTo(HaveOccurred()) + + Expect(bs.Env.InstallConfig.Codesphere.Experiments).To(Equal([]string{"fake-exp1", "fake-exp2"})) + }) + }) + Context("When feature flags are set in CodesphereEnvironment", func() { + BeforeEach(func() { + csEnv.FeatureFlags = []string{"fake-flag1", "fake-flag2"} + }) + It("uses those feature flags", func() { + icg.EXPECT().GenerateSecrets().Return(nil) + icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) + + nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() + + err := bs.UpdateInstallConfig() + Expect(err).NotTo(HaveOccurred()) + + Expect(bs.Env.InstallConfig.Codesphere.Features).To(Equal([]string{"fake-flag1", "fake-flag2"})) + }) + }) + Context("When GitHub App name is not set ", func() { + BeforeEach(func() { + csEnv.GitHubAppName = "" + }) + It("skips setting GitHub OAuth configuration", func() { + icg.EXPECT().GenerateSecrets().Return(nil) + icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) + + nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() + + err := bs.UpdateInstallConfig() + Expect(err).NotTo(HaveOccurred()) + + Expect(bs.Env.InstallConfig.Codesphere.GitProviders.GitHub).To(BeNil()) + }) + + }) + + Context("When OpenBao config is set", func() { + BeforeEach(func() { + csEnv.OpenBaoURI = "https://openbao.example.com" + csEnv.OpenBaoPassword = "fake-password" + csEnv.OpenBaoUser = "fake-username" + csEnv.OpenBaoEngine = "fake-engine" + }) + It("sets OpenBao config in install config", func() { + icg.EXPECT().GenerateSecrets().Return(nil) + icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) + + nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() + + err := bs.UpdateInstallConfig() + Expect(err).NotTo(HaveOccurred()) + + Expect(bs.Env.InstallConfig.Codesphere.OpenBao.URI).To(Equal("https://openbao.example.com")) + Expect(bs.Env.InstallConfig.Codesphere.OpenBao.Password).To(Equal("fake-password")) + Expect(bs.Env.InstallConfig.Codesphere.OpenBao.User).To(Equal("fake-username")) + Expect(bs.Env.InstallConfig.Codesphere.OpenBao.Engine).To(Equal("fake-engine")) + }) + }) + }) + + Describe("Invalid cases", func() { + It("fails when GenerateSecrets fails", func() { + icg.EXPECT().GenerateSecrets().Return(fmt.Errorf("generate error")) + + err := bs.UpdateInstallConfig() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to generate secrets")) + }) + + It("fails when WriteInstallConfig fails", func() { + icg.EXPECT().GenerateSecrets().Return(nil) + icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(fmt.Errorf("write error")) + + err := bs.UpdateInstallConfig() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to write config file")) + }) + + It("fails when WriteVault fails", func() { + icg.EXPECT().GenerateSecrets().Return(nil) + icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(fmt.Errorf("vault write error")) + + err := bs.UpdateInstallConfig() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to write vault file")) + }) + + It("fails when CopyFile config fails", func() { + icg.EXPECT().GenerateSecrets().Return(nil) + icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) + + nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(fmt.Errorf("copy error")).Once() + + err := bs.UpdateInstallConfig() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to copy install config to jumpbox")) + }) + + It("fails when CopyFile secrets fails", func() { + icg.EXPECT().GenerateSecrets().Return(nil) + icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) + + nodeClient.EXPECT().CopyFile(mock.Anything, "fake-config-file", mock.Anything).Return(nil).Once() + nodeClient.EXPECT().CopyFile(mock.Anything, "fake-secret", mock.Anything).Return(fmt.Errorf("copy error")).Once() + + err := bs.UpdateInstallConfig() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to copy secrets file to jumpbox")) + }) + }) + }) + + Describe("EnsureAgeKey", func() { + Describe("Valid EnsureAgeKey", func() { + It("generates key if missing", func() { + nodeClient.EXPECT().HasFile(mock.MatchedBy(jumpbboxMatcher), "/etc/codesphere/secrets/age_key.txt").Return(false) + nodeClient.EXPECT().RunCommand(mock.MatchedBy(jumpbboxMatcher), "root", "mkdir -p /etc/codesphere/secrets; age-keygen -o /etc/codesphere/secrets/age_key.txt").Return(nil) + + err := bs.EnsureAgeKey() + Expect(err).NotTo(HaveOccurred()) + }) + + It("skips if key exists", func() { + nodeClient.EXPECT().HasFile(mock.MatchedBy(jumpbboxMatcher), "/etc/codesphere/secrets/age_key.txt").Return(true) + + err := bs.EnsureAgeKey() + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Describe("Invalid cases", func() { + It("fails when age-keygen command fails", func() { + + nodeClient.EXPECT().HasFile(mock.MatchedBy(jumpbboxMatcher), "/etc/codesphere/secrets/age_key.txt").Return(false) + nodeClient.EXPECT().RunCommand(mock.MatchedBy(jumpbboxMatcher), "root", "mkdir -p /etc/codesphere/secrets; age-keygen -o /etc/codesphere/secrets/age_key.txt").Return(fmt.Errorf("ouch")) + + err := bs.EnsureAgeKey() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to generate age key on jumpbox")) + }) + }) + }) + + Describe("EncryptVault", func() { + Describe("Valid EncryptVault", func() { + It("encrypts vault using sops", func() { + nodeClient.EXPECT().RunCommand(bs.Env.Jumpbox, "root", mock.MatchedBy(func(cmd string) bool { + return strings.HasPrefix(cmd, "cp ") + })).Return(nil) + + nodeClient.EXPECT().RunCommand(bs.Env.Jumpbox, "root", mock.MatchedBy(func(cmd string) bool { + return strings.Contains(cmd, "sops --encrypt") + })).Return(nil) + + err := bs.EncryptVault() + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Describe("Invalid cases", func() { + It("fails when backup vault command fails", func() { + nodeClient.EXPECT().RunCommand(bs.Env.Jumpbox, "root", mock.MatchedBy(func(cmd string) bool { + return strings.HasPrefix(cmd, "cp ") + })).Return(fmt.Errorf("backup error")) + + err := bs.EncryptVault() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed backup vault on jumpbox")) + }) + + It("fails when sops encrypt command fails", func() { + nodeClient.EXPECT().RunCommand(bs.Env.Jumpbox, "root", mock.MatchedBy(func(cmd string) bool { + return strings.HasPrefix(cmd, "cp ") + })).Return(nil) + + nodeClient.EXPECT().RunCommand(bs.Env.Jumpbox, "root", mock.MatchedBy(func(cmd string) bool { + return strings.Contains(cmd, "sops --encrypt") + })).Return(fmt.Errorf("encrypt error")) + + err := bs.EncryptVault() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to encrypt vault on jumpbox")) + }) + }) + }) + +}) diff --git a/internal/installer/node/mocks.go b/internal/installer/node/mocks.go index f88c2503..6d04b8b7 100644 --- a/internal/installer/node/mocks.go +++ b/internal/installer/node/mocks.go @@ -99,6 +99,69 @@ func (_c *MockNodeClient_CopyFile_Call) RunAndReturn(run func(n *Node, src strin return _c } +// DownloadFile provides a mock function for the type MockNodeClient +func (_mock *MockNodeClient) DownloadFile(n *Node, src string, dst string) error { + ret := _mock.Called(n, src, dst) + + if len(ret) == 0 { + panic("no return value specified for DownloadFile") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(*Node, string, string) error); ok { + r0 = returnFunc(n, src, dst) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockNodeClient_DownloadFile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DownloadFile' +type MockNodeClient_DownloadFile_Call struct { + *mock.Call +} + +// DownloadFile is a helper method to define mock.On call +// - n *Node +// - src string +// - dst string +func (_e *MockNodeClient_Expecter) DownloadFile(n interface{}, src interface{}, dst interface{}) *MockNodeClient_DownloadFile_Call { + return &MockNodeClient_DownloadFile_Call{Call: _e.mock.On("DownloadFile", n, src, dst)} +} + +func (_c *MockNodeClient_DownloadFile_Call) Run(run func(n *Node, src string, dst string)) *MockNodeClient_DownloadFile_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 *Node + if args[0] != nil { + arg0 = args[0].(*Node) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockNodeClient_DownloadFile_Call) Return(err error) *MockNodeClient_DownloadFile_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockNodeClient_DownloadFile_Call) RunAndReturn(run func(n *Node, src string, dst string) error) *MockNodeClient_DownloadFile_Call { + _c.Call.Return(run) + return _c +} + // HasFile provides a mock function for the type MockNodeClient func (_mock *MockNodeClient) HasFile(n *Node, filePath string) bool { ret := _mock.Called(n, filePath) diff --git a/internal/installer/node/node.go b/internal/installer/node/node.go index 3e6c1e01..196b7eb2 100644 --- a/internal/installer/node/node.go +++ b/internal/installer/node/node.go @@ -42,6 +42,7 @@ type Node struct { type NodeClient interface { RunCommand(n *Node, username string, command string) error CopyFile(n *Node, src string, dst string) error + DownloadFile(n *Node, src string, dst string) error WaitReady(n *Node, timeout time.Duration) error HasFile(n *Node, filePath string) bool } @@ -294,18 +295,36 @@ func (c *SSHNodeClient) HasFile(n *Node, filePath string) bool { // CopyFile copies a file from the local system to the remote node via SFTP func (c *SSHNodeClient) CopyFile(n *Node, src string, dst string) error { - if n.Jumpbox == nil { - err := n.ensureDirectoryExists("root", filepath.Dir(dst)) - if err != nil { - return fmt.Errorf("failed to ensure directory exists: %w", err) - } - return n.copyFile("", n.ExternalIP, "root", src, dst) + jumpBoxIP := "" + nodeIP := n.ExternalIP + if n.Jumpbox != nil { + jumpBoxIP = n.Jumpbox.ExternalIP + nodeIP = n.InternalIP } + err := n.ensureDirectoryExists("root", filepath.Dir(dst)) if err != nil { return fmt.Errorf("failed to ensure directory exists: %w", err) } - return n.copyFile(n.Jumpbox.ExternalIP, n.InternalIP, "root", src, dst) + + return n.copyFile(jumpBoxIP, nodeIP, "root", src, dst) +} + +// DownloadFile downloads a file from the remote node to the local system via SFTP +func (c *SSHNodeClient) DownloadFile(n *Node, src, dst string) error { + jumpBoxIP := "" + nodeIP := n.ExternalIP + if n.Jumpbox != nil { + jumpBoxIP = n.Jumpbox.ExternalIP + nodeIP = n.InternalIP + } + + err := n.ensureDirectoryExists("root", filepath.Dir(dst)) + if err != nil { + return fmt.Errorf("failed to ensure directory exists: %w", err) + } + + return n.downloadFile(jumpBoxIP, nodeIP, "root", src, dst) } // Helper functions @@ -487,6 +506,34 @@ func (n *Node) copyFile(jumpboxIp string, ip string, username string, src string return nil } +// downloadFile downloads a file from the remote node to the local system via SFTP +func (n *Node) downloadFile(jumpboxIp, ip, username, src, dst string) error { + client, err := n.getSFTPClient(jumpboxIp, ip, username) + if err != nil { + return fmt.Errorf("failed to get SSH client: %v", err) + } + defer util.IgnoreError(client.Close) + + srcFile, err := client.Open(src) + if err != nil { + return fmt.Errorf("failed to open source file %s: %v", src, err) + } + defer util.IgnoreError(srcFile.Close) + + dstFile, err := n.FileIO.Create(dst) + if err != nil { + return fmt.Errorf("failed to create destination file %s: %v", dst, err) + } + defer util.IgnoreError(dstFile.Close) + + _, err = dstFile.ReadFrom(srcFile) + if err != nil { + return fmt.Errorf("failed to download data from %s to %s: %v", src, dst, err) + } + + return nil +} + // getAuthMethods constructs a slice of ssh.AuthMethod, prioritizing the SSH agent. func (n *Node) getAuthMethods() ([]ssh.AuthMethod, error) { var signers []ssh.Signer diff --git a/internal/tmpl/NOTICE b/internal/tmpl/NOTICE index 345501ed..24db1423 100644 --- a/internal/tmpl/NOTICE +++ b/internal/tmpl/NOTICE @@ -141,6 +141,12 @@ Version: v0.0.0-20230301143203-a9d515a09cc2 License: MIT License URL: https://github.com/asaskevich/govalidator/blob/a9d515a09cc2/LICENSE +---------- +Module: github.com/avast/retry-go/v5 +Version: v5.0.0 +License: MIT +License URL: https://github.com/avast/retry-go/blob/v5.0.0/LICENSE + ---------- Module: github.com/beorn7/perks/quantile Version: v1.0.1 @@ -185,21 +191,21 @@ License URL: https://github.com/cloudflare/circl/blob/v1.6.3/LICENSE ---------- Module: github.com/cloudnative-pg/barman-cloud/pkg/api -Version: v0.4.1-0.20260108104508-ced266c145f5 +Version: v0.5.0 License: Apache-2.0 -License URL: https://github.com/cloudnative-pg/barman-cloud/blob/ced266c145f5/LICENSE +License URL: https://github.com/cloudnative-pg/barman-cloud/blob/v0.5.0/LICENSE ---------- Module: github.com/cloudnative-pg/cloudnative-pg -Version: v1.28.1 +Version: v1.29.0 License: Apache-2.0 -License URL: https://github.com/cloudnative-pg/cloudnative-pg/blob/v1.28.1/LICENSE +License URL: https://github.com/cloudnative-pg/cloudnative-pg/blob/v1.29.0/LICENSE ---------- Module: github.com/cloudnative-pg/cnpg-i/pkg/identity -Version: v0.3.1 +Version: v0.5.0 License: Apache-2.0 -License URL: https://github.com/cloudnative-pg/cnpg-i/blob/v0.3.1/LICENSE +License URL: https://github.com/cloudnative-pg/cnpg-i/blob/v0.5.0/LICENSE ---------- Module: github.com/cloudnative-pg/machinery/pkg @@ -1163,15 +1169,15 @@ License URL: https://github.com/gomodules/jsonpatch/blob/v2.5.0/v2/LICENSE ---------- Module: google.golang.org/api -Version: v0.273.0 +Version: v0.273.1 License: BSD-3-Clause -License URL: https://github.com/googleapis/google-api-go-client/blob/v0.273.0/LICENSE +License URL: https://github.com/googleapis/google-api-go-client/blob/v0.273.1/LICENSE ---------- Module: google.golang.org/api/internal/third_party/uritemplates -Version: v0.273.0 +Version: v0.273.1 License: BSD-3-Clause -License URL: https://github.com/googleapis/google-api-go-client/blob/v0.273.0/internal/third_party/uritemplates/LICENSE +License URL: https://github.com/googleapis/google-api-go-client/blob/v0.273.1/internal/third_party/uritemplates/LICENSE ---------- Module: google.golang.org/genproto/googleapis @@ -1193,9 +1199,9 @@ License URL: https://github.com/googleapis/go-genproto/blob/d00831a3d3e7/googlea ---------- Module: google.golang.org/grpc -Version: v1.79.3 +Version: v1.80.0 License: Apache-2.0 -License URL: https://github.com/grpc/grpc-go/blob/v1.79.3/LICENSE +License URL: https://github.com/grpc/grpc-go/blob/v1.80.0/LICENSE ---------- Module: google.golang.org/protobuf