From 03050c17bbd01bc0c84584ec43ddc9b4ba28bfa3 Mon Sep 17 00:00:00 2001 From: Jonas Kauke Date: Tue, 31 Mar 2026 14:51:15 +0200 Subject: [PATCH 01/15] chore: split config and secrets functions --- internal/bootstrap/gcp/gcp.go | 265 ----------- internal/bootstrap/gcp/iam_admin.go | 1 + internal/bootstrap/gcp/install_config.go | 274 +++++++++++ internal/bootstrap/gcp/install_config_test.go | 442 ++++++++++++++++++ 4 files changed, 717 insertions(+), 265 deletions(-) create mode 100644 internal/bootstrap/gcp/install_config.go create mode 100644 internal/bootstrap/gcp/install_config_test.go diff --git a/internal/bootstrap/gcp/gcp.go b/internal/bootstrap/gcp/gcp.go index 6367ae1d..5a414293 100644 --- a/internal/bootstrap/gcp/gcp.go +++ b/internal/bootstrap/gcp/gcp.go @@ -405,43 +405,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 +760,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/iam_admin.go b/internal/bootstrap/gcp/iam_admin.go index c2a344ef..61a6137a 100644 --- a/internal/bootstrap/gcp/iam_admin.go +++ b/internal/bootstrap/gcp/iam_admin.go @@ -164,6 +164,7 @@ func (b *GCPBootstrapper) EnsureAPIsEnabled() error { "serviceusage.googleapis.com", "artifactregistry.googleapis.com", "dns.googleapis.com", + "secretmanager.googleapis.com", } err := b.GCPClient.EnableAPIs(b.Env.ProjectID, apis) diff --git a/internal/bootstrap/gcp/install_config.go b/internal/bootstrap/gcp/install_config.go new file mode 100644 index 00000000..ff68f97d --- /dev/null +++ b/internal/bootstrap/gcp/install_config.go @@ -0,0 +1,274 @@ +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" +) + +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) 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) 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..342af6ca --- /dev/null +++ b/internal/bootstrap/gcp/install_config_test.go @@ -0,0 +1,442 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package gcp_test + +import ( + "context" + "fmt" + "strings" + + "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" +) + +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", + 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("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("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")) + }) + }) + }) + +}) From 419ac60d4efbd5909e08cd012e273996a5e52e4a Mon Sep 17 00:00:00 2001 From: Jonas Kauke Date: Tue, 31 Mar 2026 16:47:59 +0200 Subject: [PATCH 02/15] feat: upload config to secret manager --- go.mod | 1 + go.sum | 2 + internal/bootstrap/gcp/gcp_client.go | 44 +++++++++++++++++ internal/bootstrap/gcp/iam_admin_test.go | 1 + internal/bootstrap/gcp/install_config.go | 62 +++++++++++++++++++++++ internal/bootstrap/gcp/mocks.go | 63 ++++++++++++++++++++++++ 6 files changed, 173 insertions(+) diff --git a/go.mod b/go.mod index dadd0e53..bfc67204 100644 --- a/go.mod +++ b/go.mod @@ -530,6 +530,7 @@ require ( ) require ( + cloud.google.com/go/secretmanager v1.16.0 filippo.io/edwards25519 v1.2.0 // indirect filippo.io/hpke v0.4.0 // indirect github.com/MakeNowJust/heredoc v1.0.0 // indirect diff --git a/go.sum b/go.sum index 041938bc..f26cd9e5 100644 --- a/go.sum +++ b/go.sum @@ -1116,6 +1116,8 @@ cloud.google.com/go/secretmanager v1.11.2/go.mod h1:MQm4t3deoSub7+WNwiC4/tRYgDBH cloud.google.com/go/secretmanager v1.11.3/go.mod h1:0bA2o6FabmShrEy328i67aV+65XoUFFSmVeLBn/51jI= cloud.google.com/go/secretmanager v1.11.4/go.mod h1:wreJlbS9Zdq21lMzWmJ0XhWW2ZxgPeahsqeV/vZoJ3w= cloud.google.com/go/secretmanager v1.11.5/go.mod h1:eAGv+DaCHkeVyQi0BeXgAHOU0RdrMeZIASKc+S7VqH4= +cloud.google.com/go/secretmanager v1.16.0 h1:19QT7ZsLJ8FSP1k+4esQvuCD7npMJml6hYzilxVyT+k= +cloud.google.com/go/secretmanager v1.16.0/go.mod h1://C/e4I8D26SDTz1f3TQcddhcmiC3rMEl0S1Cakvs3Q= cloud.google.com/go/security v1.5.0/go.mod h1:lgxGdyOKKjHL4YG3/YwIL2zLqMFCKs0UbQwgyZmfJl4= cloud.google.com/go/security v1.7.0/go.mod h1:mZklORHl6Bg7CNnnjLH//0UlAlaXqiG7Lb9PsPXLfD0= cloud.google.com/go/security v1.8.0/go.mod h1:hAQOwgmaHhztFhiQ41CjDODdWP0+AE1B3sX4OFlq+GU= diff --git a/internal/bootstrap/gcp/gcp_client.go b/internal/bootstrap/gcp/gcp_client.go index 439969b4..ddc68e8b 100644 --- a/internal/bootstrap/gcp/gcp_client.go +++ b/internal/bootstrap/gcp/gcp_client.go @@ -18,6 +18,8 @@ import ( "cloud.google.com/go/iam/apiv1/iampb" resourcemanager "cloud.google.com/go/resourcemanager/apiv3" "cloud.google.com/go/resourcemanager/apiv3/resourcemanagerpb" + secretmanager "cloud.google.com/go/secretmanager/apiv1" + "cloud.google.com/go/secretmanager/apiv1/secretmanagerpb" serviceusage "cloud.google.com/go/serviceusage/apiv1" "cloud.google.com/go/serviceusage/apiv1/serviceusagepb" "github.com/codesphere-cloud/oms/internal/bootstrap" @@ -61,6 +63,8 @@ type GCPClientManager interface { EnsureDNSManagedZone(projectID, zoneName, dnsName, description string) error EnsureDNSRecordSets(projectID, zoneName string, records []*dns.ResourceRecordSet) error DeleteDNSRecordSets(projectID, zoneName, baseDomain string) error + + StoreSecret(projectID, key string, value []byte) error } // Concrete implementation @@ -855,6 +859,46 @@ func (c *GCPClient) DeleteDNSRecordSets(projectID, zoneName, baseDomain string) return nil } +func (c *GCPClient) StoreSecret(projectID, key string, payload []byte) error { + client, err := secretmanager.NewClient(c.ctx) + if err != nil { + return fmt.Errorf("failed to create secretmanager client: %w", err) + } + defer client.Close() + + secretParent := fmt.Sprintf("projects/%s", projectID) + secretReq := &secretmanagerpb.CreateSecretRequest{ + Parent: secretParent, + SecretId: key, + Secret: &secretmanagerpb.Secret{ + Replication: &secretmanagerpb.Replication{ + Replication: &secretmanagerpb.Replication_Automatic_{ + Automatic: &secretmanagerpb.Replication_Automatic{}, + }, + }, + }, + } + + _, err = client.CreateSecret(c.ctx, secretReq) + if err != nil && !IsAlreadyExistsError(err) { + return fmt.Errorf("failed to create secret: %w", err) + } + + secretVersionReq := &secretmanagerpb.AddSecretVersionRequest{ + Parent: fmt.Sprintf("%s/secrets/%s", secretParent, key), + Payload: &secretmanagerpb.SecretPayload{ + Data: payload, + }, + } + + _, err = client.AddSecretVersion(c.ctx, secretVersionReq) + if err != nil { + return fmt.Errorf("failed to add secret version: %w", err) + } + + return nil +} + // Helper functions func protoString(s string) *string { return &s } func protoBool(b bool) *bool { return &b } diff --git a/internal/bootstrap/gcp/iam_admin_test.go b/internal/bootstrap/gcp/iam_admin_test.go index eabb2028..49d96249 100644 --- a/internal/bootstrap/gcp/iam_admin_test.go +++ b/internal/bootstrap/gcp/iam_admin_test.go @@ -214,6 +214,7 @@ var _ = Describe("IAM & Admin", func() { "serviceusage.googleapis.com", "artifactregistry.googleapis.com", "dns.googleapis.com", + "secretmanager.googleapis.com", }).Return(nil) err := bs.EnsureAPIsEnabled() diff --git a/internal/bootstrap/gcp/install_config.go b/internal/bootstrap/gcp/install_config.go index ff68f97d..6ba105dd 100644 --- a/internal/bootstrap/gcp/install_config.go +++ b/internal/bootstrap/gcp/install_config.go @@ -225,6 +225,27 @@ func (b *GCPBootstrapper) UpdateInstallConfig() error { if err != nil { return fmt.Errorf("failed to copy secrets file to jumpbox: %w", err) } + + err = b.UploadConfig() + if err != nil { + return fmt.Errorf("failed to upload config: %w", err) + } + + return nil +} + +// UploadConfig stores the install config and the vault in the GCP Secret Manager of the bootstrapped project +func (b *GCPBootstrapper) UploadConfig() error { + configPayload, err := b.icg.GetInstallConfig().Marshal() + if err != nil { + return fmt.Errorf("failed to marshal install config: %w", err) + } + + err = b.GCPClient.StoreSecret(b.Env.ProjectID, "config", configPayload) + if err != nil { + return fmt.Errorf("failed to store install config in GCP Secret Manager: %w", err) + } + return nil } @@ -272,3 +293,44 @@ func (b *GCPBootstrapper) EncryptVault() error { return nil } + +// func (b *GCPBootstrapper) UploadConfig() error { +// b.GCPClient.StoreSecret() +// } + +// // createSecret creates a new secret with the given name. A secret is a logical +// // wrapper around a collection of secret versions. Secret versions hold the +// // actual secret material. +// func createSecret(parent, id string) error { +// // parent := "projects/my-project" +// // id := "my-secret" + +// // Create the client. +// ctx := context.Background() +// client, err := secretmanager.NewClient(ctx) +// if err != nil { +// return fmt.Errorf("failed to create secretmanager client: %w", err) +// } +// defer client.Close() + +// // Build the request. +// req := &secretmanagerpb.CreateSecretRequest{ +// Parent: parent, +// SecretId: id, +// Secret: &secretmanagerpb.Secret{ +// Replication: &secretmanagerpb.Replication{ +// Replication: &secretmanagerpb.Replication_Automatic_{ +// Automatic: &secretmanagerpb.Replication_Automatic{}, +// }, +// }, +// }, +// } + +// // Call the API. +// _, err = client.CreateSecret(ctx, req) +// if err != nil { +// return fmt.Errorf("failed to create secret: %w", err) +// } + +// return nil +// } diff --git a/internal/bootstrap/gcp/mocks.go b/internal/bootstrap/gcp/mocks.go index 495f446f..b4af7339 100644 --- a/internal/bootstrap/gcp/mocks.go +++ b/internal/bootstrap/gcp/mocks.go @@ -1633,6 +1633,69 @@ func (_c *MockGCPClientManager_StartInstance_Call) RunAndReturn(run func(project return _c } +// StoreSecret provides a mock function for the type MockGCPClientManager +func (_mock *MockGCPClientManager) StoreSecret(projectID string, key string, value []byte) error { + ret := _mock.Called(projectID, key, value) + + if len(ret) == 0 { + panic("no return value specified for StoreSecret") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string, string, []byte) error); ok { + r0 = returnFunc(projectID, key, value) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockGCPClientManager_StoreSecret_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StoreSecret' +type MockGCPClientManager_StoreSecret_Call struct { + *mock.Call +} + +// StoreSecret is a helper method to define mock.On call +// - projectID string +// - key string +// - value []byte +func (_e *MockGCPClientManager_Expecter) StoreSecret(projectID interface{}, key interface{}, value interface{}) *MockGCPClientManager_StoreSecret_Call { + return &MockGCPClientManager_StoreSecret_Call{Call: _e.mock.On("StoreSecret", projectID, key, value)} +} + +func (_c *MockGCPClientManager_StoreSecret_Call) Run(run func(projectID string, key string, value []byte)) *MockGCPClientManager_StoreSecret_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 []byte + if args[2] != nil { + arg2 = args[2].([]byte) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockGCPClientManager_StoreSecret_Call) Return(err error) *MockGCPClientManager_StoreSecret_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockGCPClientManager_StoreSecret_Call) RunAndReturn(run func(projectID string, key string, value []byte) error) *MockGCPClientManager_StoreSecret_Call { + _c.Call.Return(run) + return _c +} + // UpdateProject provides a mock function for the type MockGCPClientManager func (_mock *MockGCPClientManager) UpdateProject(projectID string, labels map[string]string) error { ret := _mock.Called(projectID, labels) From 468c80a5a5e0b0da33f9d4889f4314f7196dec7f Mon Sep 17 00:00:00 2001 From: joka134 <27293650+joka134@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:51:37 +0000 Subject: [PATCH 03/15] chore(docs): Auto-update docs and licenses Signed-off-by: joka134 <27293650+joka134@users.noreply.github.com> --- NOTICE | 8 +++++++- internal/bootstrap/gcp/install_config.go | 3 +++ internal/tmpl/NOTICE | 8 +++++++- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/NOTICE b/NOTICE index 345501ed..d3e5ee6b 100644 --- a/NOTICE +++ b/NOTICE @@ -34,7 +34,7 @@ License: Apache-2.0 License URL: https://github.com/googleapis/google-cloud-go/blob/compute/metadata/v0.9.0/compute/metadata/LICENSE ---------- -Module: cloud.google.com/go/iam/apiv1/iampb +Module: cloud.google.com/go/iam Version: v1.6.0 License: Apache-2.0 License URL: https://github.com/googleapis/google-cloud-go/blob/iam/v1.6.0/iam/LICENSE @@ -51,6 +51,12 @@ Version: v1.10.7 License: Apache-2.0 License URL: https://github.com/googleapis/google-cloud-go/blob/resourcemanager/v1.10.7/resourcemanager/LICENSE +---------- +Module: cloud.google.com/go/secretmanager +Version: v1.16.0 +License: Apache-2.0 +License URL: https://github.com/googleapis/google-cloud-go/blob/secretmanager/v1.16.0/secretmanager/LICENSE + ---------- Module: cloud.google.com/go/serviceusage Version: v1.9.7 diff --git a/internal/bootstrap/gcp/install_config.go b/internal/bootstrap/gcp/install_config.go index 6ba105dd..5f1b0639 100644 --- a/internal/bootstrap/gcp/install_config.go +++ b/internal/bootstrap/gcp/install_config.go @@ -1,3 +1,6 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + package gcp import ( diff --git a/internal/tmpl/NOTICE b/internal/tmpl/NOTICE index 345501ed..d3e5ee6b 100644 --- a/internal/tmpl/NOTICE +++ b/internal/tmpl/NOTICE @@ -34,7 +34,7 @@ License: Apache-2.0 License URL: https://github.com/googleapis/google-cloud-go/blob/compute/metadata/v0.9.0/compute/metadata/LICENSE ---------- -Module: cloud.google.com/go/iam/apiv1/iampb +Module: cloud.google.com/go/iam Version: v1.6.0 License: Apache-2.0 License URL: https://github.com/googleapis/google-cloud-go/blob/iam/v1.6.0/iam/LICENSE @@ -51,6 +51,12 @@ Version: v1.10.7 License: Apache-2.0 License URL: https://github.com/googleapis/google-cloud-go/blob/resourcemanager/v1.10.7/resourcemanager/LICENSE +---------- +Module: cloud.google.com/go/secretmanager +Version: v1.16.0 +License: Apache-2.0 +License URL: https://github.com/googleapis/google-cloud-go/blob/secretmanager/v1.16.0/secretmanager/LICENSE + ---------- Module: cloud.google.com/go/serviceusage Version: v1.9.7 From 1a4829b6759c6de5d05fbf4278be7f880907f21e Mon Sep 17 00:00:00 2001 From: Jonas Kauke Date: Wed, 1 Apr 2026 10:04:34 +0200 Subject: [PATCH 04/15] chore: cleanup --- internal/bootstrap/gcp/gcp_client.go | 44 ----------------- internal/bootstrap/gcp/iam_admin.go | 1 - internal/bootstrap/gcp/iam_admin_test.go | 1 - internal/bootstrap/gcp/install_config.go | 20 -------- internal/bootstrap/gcp/mocks.go | 63 ------------------------ 5 files changed, 129 deletions(-) diff --git a/internal/bootstrap/gcp/gcp_client.go b/internal/bootstrap/gcp/gcp_client.go index ddc68e8b..439969b4 100644 --- a/internal/bootstrap/gcp/gcp_client.go +++ b/internal/bootstrap/gcp/gcp_client.go @@ -18,8 +18,6 @@ import ( "cloud.google.com/go/iam/apiv1/iampb" resourcemanager "cloud.google.com/go/resourcemanager/apiv3" "cloud.google.com/go/resourcemanager/apiv3/resourcemanagerpb" - secretmanager "cloud.google.com/go/secretmanager/apiv1" - "cloud.google.com/go/secretmanager/apiv1/secretmanagerpb" serviceusage "cloud.google.com/go/serviceusage/apiv1" "cloud.google.com/go/serviceusage/apiv1/serviceusagepb" "github.com/codesphere-cloud/oms/internal/bootstrap" @@ -63,8 +61,6 @@ type GCPClientManager interface { EnsureDNSManagedZone(projectID, zoneName, dnsName, description string) error EnsureDNSRecordSets(projectID, zoneName string, records []*dns.ResourceRecordSet) error DeleteDNSRecordSets(projectID, zoneName, baseDomain string) error - - StoreSecret(projectID, key string, value []byte) error } // Concrete implementation @@ -859,46 +855,6 @@ func (c *GCPClient) DeleteDNSRecordSets(projectID, zoneName, baseDomain string) return nil } -func (c *GCPClient) StoreSecret(projectID, key string, payload []byte) error { - client, err := secretmanager.NewClient(c.ctx) - if err != nil { - return fmt.Errorf("failed to create secretmanager client: %w", err) - } - defer client.Close() - - secretParent := fmt.Sprintf("projects/%s", projectID) - secretReq := &secretmanagerpb.CreateSecretRequest{ - Parent: secretParent, - SecretId: key, - Secret: &secretmanagerpb.Secret{ - Replication: &secretmanagerpb.Replication{ - Replication: &secretmanagerpb.Replication_Automatic_{ - Automatic: &secretmanagerpb.Replication_Automatic{}, - }, - }, - }, - } - - _, err = client.CreateSecret(c.ctx, secretReq) - if err != nil && !IsAlreadyExistsError(err) { - return fmt.Errorf("failed to create secret: %w", err) - } - - secretVersionReq := &secretmanagerpb.AddSecretVersionRequest{ - Parent: fmt.Sprintf("%s/secrets/%s", secretParent, key), - Payload: &secretmanagerpb.SecretPayload{ - Data: payload, - }, - } - - _, err = client.AddSecretVersion(c.ctx, secretVersionReq) - if err != nil { - return fmt.Errorf("failed to add secret version: %w", err) - } - - return nil -} - // Helper functions func protoString(s string) *string { return &s } func protoBool(b bool) *bool { return &b } diff --git a/internal/bootstrap/gcp/iam_admin.go b/internal/bootstrap/gcp/iam_admin.go index 61a6137a..c2a344ef 100644 --- a/internal/bootstrap/gcp/iam_admin.go +++ b/internal/bootstrap/gcp/iam_admin.go @@ -164,7 +164,6 @@ func (b *GCPBootstrapper) EnsureAPIsEnabled() error { "serviceusage.googleapis.com", "artifactregistry.googleapis.com", "dns.googleapis.com", - "secretmanager.googleapis.com", } err := b.GCPClient.EnableAPIs(b.Env.ProjectID, apis) diff --git a/internal/bootstrap/gcp/iam_admin_test.go b/internal/bootstrap/gcp/iam_admin_test.go index 49d96249..eabb2028 100644 --- a/internal/bootstrap/gcp/iam_admin_test.go +++ b/internal/bootstrap/gcp/iam_admin_test.go @@ -214,7 +214,6 @@ var _ = Describe("IAM & Admin", func() { "serviceusage.googleapis.com", "artifactregistry.googleapis.com", "dns.googleapis.com", - "secretmanager.googleapis.com", }).Return(nil) err := bs.EnsureAPIsEnabled() diff --git a/internal/bootstrap/gcp/install_config.go b/internal/bootstrap/gcp/install_config.go index 5f1b0639..526d5586 100644 --- a/internal/bootstrap/gcp/install_config.go +++ b/internal/bootstrap/gcp/install_config.go @@ -229,26 +229,6 @@ func (b *GCPBootstrapper) UpdateInstallConfig() error { return fmt.Errorf("failed to copy secrets file to jumpbox: %w", err) } - err = b.UploadConfig() - if err != nil { - return fmt.Errorf("failed to upload config: %w", err) - } - - return nil -} - -// UploadConfig stores the install config and the vault in the GCP Secret Manager of the bootstrapped project -func (b *GCPBootstrapper) UploadConfig() error { - configPayload, err := b.icg.GetInstallConfig().Marshal() - if err != nil { - return fmt.Errorf("failed to marshal install config: %w", err) - } - - err = b.GCPClient.StoreSecret(b.Env.ProjectID, "config", configPayload) - if err != nil { - return fmt.Errorf("failed to store install config in GCP Secret Manager: %w", err) - } - return nil } diff --git a/internal/bootstrap/gcp/mocks.go b/internal/bootstrap/gcp/mocks.go index b4af7339..495f446f 100644 --- a/internal/bootstrap/gcp/mocks.go +++ b/internal/bootstrap/gcp/mocks.go @@ -1633,69 +1633,6 @@ func (_c *MockGCPClientManager_StartInstance_Call) RunAndReturn(run func(project return _c } -// StoreSecret provides a mock function for the type MockGCPClientManager -func (_mock *MockGCPClientManager) StoreSecret(projectID string, key string, value []byte) error { - ret := _mock.Called(projectID, key, value) - - if len(ret) == 0 { - panic("no return value specified for StoreSecret") - } - - var r0 error - if returnFunc, ok := ret.Get(0).(func(string, string, []byte) error); ok { - r0 = returnFunc(projectID, key, value) - } else { - r0 = ret.Error(0) - } - return r0 -} - -// MockGCPClientManager_StoreSecret_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StoreSecret' -type MockGCPClientManager_StoreSecret_Call struct { - *mock.Call -} - -// StoreSecret is a helper method to define mock.On call -// - projectID string -// - key string -// - value []byte -func (_e *MockGCPClientManager_Expecter) StoreSecret(projectID interface{}, key interface{}, value interface{}) *MockGCPClientManager_StoreSecret_Call { - return &MockGCPClientManager_StoreSecret_Call{Call: _e.mock.On("StoreSecret", projectID, key, value)} -} - -func (_c *MockGCPClientManager_StoreSecret_Call) Run(run func(projectID string, key string, value []byte)) *MockGCPClientManager_StoreSecret_Call { - _c.Call.Run(func(args mock.Arguments) { - var arg0 string - if args[0] != nil { - arg0 = args[0].(string) - } - var arg1 string - if args[1] != nil { - arg1 = args[1].(string) - } - var arg2 []byte - if args[2] != nil { - arg2 = args[2].([]byte) - } - run( - arg0, - arg1, - arg2, - ) - }) - return _c -} - -func (_c *MockGCPClientManager_StoreSecret_Call) Return(err error) *MockGCPClientManager_StoreSecret_Call { - _c.Call.Return(err) - return _c -} - -func (_c *MockGCPClientManager_StoreSecret_Call) RunAndReturn(run func(projectID string, key string, value []byte) error) *MockGCPClientManager_StoreSecret_Call { - _c.Call.Return(run) - return _c -} - // UpdateProject provides a mock function for the type MockGCPClientManager func (_mock *MockGCPClientManager) UpdateProject(projectID string, labels map[string]string) error { ret := _mock.Called(projectID, labels) From ab71a78544f9a5e4678ebf5677a2b0d0462e5cbd Mon Sep 17 00:00:00 2001 From: Jonas Kauke Date: Wed, 1 Apr 2026 10:05:29 +0200 Subject: [PATCH 05/15] chore: cleanup --- go.mod | 1 - go.sum | 2 -- 2 files changed, 3 deletions(-) diff --git a/go.mod b/go.mod index bfc67204..dadd0e53 100644 --- a/go.mod +++ b/go.mod @@ -530,7 +530,6 @@ require ( ) require ( - cloud.google.com/go/secretmanager v1.16.0 filippo.io/edwards25519 v1.2.0 // indirect filippo.io/hpke v0.4.0 // indirect github.com/MakeNowJust/heredoc v1.0.0 // indirect diff --git a/go.sum b/go.sum index f26cd9e5..041938bc 100644 --- a/go.sum +++ b/go.sum @@ -1116,8 +1116,6 @@ cloud.google.com/go/secretmanager v1.11.2/go.mod h1:MQm4t3deoSub7+WNwiC4/tRYgDBH cloud.google.com/go/secretmanager v1.11.3/go.mod h1:0bA2o6FabmShrEy328i67aV+65XoUFFSmVeLBn/51jI= cloud.google.com/go/secretmanager v1.11.4/go.mod h1:wreJlbS9Zdq21lMzWmJ0XhWW2ZxgPeahsqeV/vZoJ3w= cloud.google.com/go/secretmanager v1.11.5/go.mod h1:eAGv+DaCHkeVyQi0BeXgAHOU0RdrMeZIASKc+S7VqH4= -cloud.google.com/go/secretmanager v1.16.0 h1:19QT7ZsLJ8FSP1k+4esQvuCD7npMJml6hYzilxVyT+k= -cloud.google.com/go/secretmanager v1.16.0/go.mod h1://C/e4I8D26SDTz1f3TQcddhcmiC3rMEl0S1Cakvs3Q= cloud.google.com/go/security v1.5.0/go.mod h1:lgxGdyOKKjHL4YG3/YwIL2zLqMFCKs0UbQwgyZmfJl4= cloud.google.com/go/security v1.7.0/go.mod h1:mZklORHl6Bg7CNnnjLH//0UlAlaXqiG7Lb9PsPXLfD0= cloud.google.com/go/security v1.8.0/go.mod h1:hAQOwgmaHhztFhiQ41CjDODdWP0+AE1B3sX4OFlq+GU= From 792b3a170d13b39c3bfba4fbb5c62cd5026769f7 Mon Sep 17 00:00:00 2001 From: Jonas Kauke Date: Wed, 1 Apr 2026 10:05:51 +0200 Subject: [PATCH 06/15] chore: cleanup --- internal/bootstrap/gcp/install_config.go | 41 ------------------------ 1 file changed, 41 deletions(-) diff --git a/internal/bootstrap/gcp/install_config.go b/internal/bootstrap/gcp/install_config.go index 526d5586..15650835 100644 --- a/internal/bootstrap/gcp/install_config.go +++ b/internal/bootstrap/gcp/install_config.go @@ -276,44 +276,3 @@ func (b *GCPBootstrapper) EncryptVault() error { return nil } - -// func (b *GCPBootstrapper) UploadConfig() error { -// b.GCPClient.StoreSecret() -// } - -// // createSecret creates a new secret with the given name. A secret is a logical -// // wrapper around a collection of secret versions. Secret versions hold the -// // actual secret material. -// func createSecret(parent, id string) error { -// // parent := "projects/my-project" -// // id := "my-secret" - -// // Create the client. -// ctx := context.Background() -// client, err := secretmanager.NewClient(ctx) -// if err != nil { -// return fmt.Errorf("failed to create secretmanager client: %w", err) -// } -// defer client.Close() - -// // Build the request. -// req := &secretmanagerpb.CreateSecretRequest{ -// Parent: parent, -// SecretId: id, -// Secret: &secretmanagerpb.Secret{ -// Replication: &secretmanagerpb.Replication{ -// Replication: &secretmanagerpb.Replication_Automatic_{ -// Automatic: &secretmanagerpb.Replication_Automatic{}, -// }, -// }, -// }, -// } - -// // Call the API. -// _, err = client.CreateSecret(ctx, req) -// if err != nil { -// return fmt.Errorf("failed to create secret: %w", err) -// } - -// return nil -// } From 8e62dd952bca83379786881b8300dd5bbbe01383 Mon Sep 17 00:00:00 2001 From: joka134 <27293650+joka134@users.noreply.github.com> Date: Wed, 1 Apr 2026 08:06:57 +0000 Subject: [PATCH 07/15] chore(docs): Auto-update docs and licenses Signed-off-by: joka134 <27293650+joka134@users.noreply.github.com> --- NOTICE | 8 +------- internal/tmpl/NOTICE | 8 +------- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/NOTICE b/NOTICE index d3e5ee6b..345501ed 100644 --- a/NOTICE +++ b/NOTICE @@ -34,7 +34,7 @@ License: Apache-2.0 License URL: https://github.com/googleapis/google-cloud-go/blob/compute/metadata/v0.9.0/compute/metadata/LICENSE ---------- -Module: cloud.google.com/go/iam +Module: cloud.google.com/go/iam/apiv1/iampb Version: v1.6.0 License: Apache-2.0 License URL: https://github.com/googleapis/google-cloud-go/blob/iam/v1.6.0/iam/LICENSE @@ -51,12 +51,6 @@ Version: v1.10.7 License: Apache-2.0 License URL: https://github.com/googleapis/google-cloud-go/blob/resourcemanager/v1.10.7/resourcemanager/LICENSE ----------- -Module: cloud.google.com/go/secretmanager -Version: v1.16.0 -License: Apache-2.0 -License URL: https://github.com/googleapis/google-cloud-go/blob/secretmanager/v1.16.0/secretmanager/LICENSE - ---------- Module: cloud.google.com/go/serviceusage Version: v1.9.7 diff --git a/internal/tmpl/NOTICE b/internal/tmpl/NOTICE index d3e5ee6b..345501ed 100644 --- a/internal/tmpl/NOTICE +++ b/internal/tmpl/NOTICE @@ -34,7 +34,7 @@ License: Apache-2.0 License URL: https://github.com/googleapis/google-cloud-go/blob/compute/metadata/v0.9.0/compute/metadata/LICENSE ---------- -Module: cloud.google.com/go/iam +Module: cloud.google.com/go/iam/apiv1/iampb Version: v1.6.0 License: Apache-2.0 License URL: https://github.com/googleapis/google-cloud-go/blob/iam/v1.6.0/iam/LICENSE @@ -51,12 +51,6 @@ Version: v1.10.7 License: Apache-2.0 License URL: https://github.com/googleapis/google-cloud-go/blob/resourcemanager/v1.10.7/resourcemanager/LICENSE ----------- -Module: cloud.google.com/go/secretmanager -Version: v1.16.0 -License: Apache-2.0 -License URL: https://github.com/googleapis/google-cloud-go/blob/secretmanager/v1.16.0/secretmanager/LICENSE - ---------- Module: cloud.google.com/go/serviceusage Version: v1.9.7 From 4450f8526efe3797cc89a69459b67ca28308a471 Mon Sep 17 00:00:00 2001 From: Jonas Kauke Date: Wed, 1 Apr 2026 14:12:16 +0200 Subject: [PATCH 08/15] feat: recover config on startup from jumpbox --- cli/cmd/bootstrap_gcp.go | 12 +++-- internal/bootstrap/gcp/gce.go | 17 +++++++ internal/bootstrap/gcp/gcp.go | 1 + internal/bootstrap/gcp/install_config.go | 39 ++++++++++++++- internal/installer/node/mocks.go | 63 ++++++++++++++++++++++++ internal/installer/node/node.go | 49 ++++++++++++++++++ 6 files changed, 174 insertions(+), 7 deletions(-) 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/internal/bootstrap/gcp/gce.go b/internal/bootstrap/gcp/gce.go index 31039168..0c618898 100644 --- a/internal/bootstrap/gcp/gce.go +++ b/internal/bootstrap/gcp/gce.go @@ -379,3 +379,20 @@ func (b *GCPBootstrapper) ReadSSHKey(path string) (string, error) { } return key, nil } + +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) + } + + internalIP, externalIP := ExtractInstanceIPs(existingInstance) + + existingNode := &node.Node{ + NodeClient: b.NodeClient, + FileIO: b.fw, + } + existingNode.UpdateNode(name, externalIP, internalIP) + + return existingNode, nil +} diff --git a/internal/bootstrap/gcp/gcp.go b/internal/bootstrap/gcp/gcp.go index 5a414293..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"` diff --git a/internal/bootstrap/gcp/install_config.go b/internal/bootstrap/gcp/install_config.go index 15650835..674d7fa4 100644 --- a/internal/bootstrap/gcp/install_config.go +++ b/internal/bootstrap/gcp/install_config.go @@ -11,8 +11,22 @@ import ( "github.com/codesphere-cloud/oms/internal/installer/files" ) +const ( + remoteInstallConfigPath string = "/etc/codesphere/config.yaml" +) + +// EnsureInstallConfig recovers the config either from the jumpbox or from a local file if desired. +// Else it applies the minimal profile to a new config. func (b *GCPBootstrapper) EnsureInstallConfig() error { - if b.fw.Exists(b.Env.InstallConfigPath) { + if b.fw.Exists(b.Env.InstallConfigPath) || b.Env.RecoverConfig { + // recovery will overwrite local config + if b.Env.RecoverConfig { + err := b.recoverConfig() + if err != nil { + return fmt.Errorf("failed to recover config: %w", err) + } + } + err := b.icg.LoadInstallConfigFromFile(b.Env.InstallConfigPath) if err != nil { return fmt.Errorf("failed to load config file: %w", err) @@ -31,6 +45,27 @@ func (b *GCPBootstrapper) EnsureInstallConfig() error { return nil } +func (b *GCPBootstrapper) recoverConfig() error { + // check if gcp project exists for recovery + 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) + } + + return nil +} + func (b *GCPBootstrapper) UpdateInstallConfig() error { // Update install config with necessary values b.Env.InstallConfig.Datacenter.ID = b.Env.DatacenterID @@ -219,7 +254,7 @@ func (b *GCPBootstrapper) UpdateInstallConfig() error { 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") + 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) } 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..f499243f 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 } @@ -299,15 +300,35 @@ func (c *SSHNodeClient) CopyFile(n *Node, src string, dst string) error { if err != nil { return fmt.Errorf("failed to ensure directory exists: %w", err) } + return n.copyFile("", n.ExternalIP, "root", src, dst) } + 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) } +// DownloadFile copies 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 // hasSysctlLine checks if a specific line exists in /etc/sysctl.conf on the remote node via SSH @@ -487,6 +508,34 @@ func (n *Node) copyFile(jumpboxIp string, ip string, username string, src string return nil } +// downloadFile copies 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 copy 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 From 486948978e9a61b9b7fa08041fefb48ad40225e8 Mon Sep 17 00:00:00 2001 From: joka134 <27293650+joka134@users.noreply.github.com> Date: Wed, 1 Apr 2026 12:13:37 +0000 Subject: [PATCH 09/15] chore(docs): Auto-update docs and licenses Signed-off-by: joka134 <27293650+joka134@users.noreply.github.com> --- docs/oms_beta_bootstrap-gcp.md | 1 + 1 file changed, 1 insertion(+) 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) From 74533a48a190f6499e5c305551b6efb6cdfde1d6 Mon Sep 17 00:00:00 2001 From: Jonas Kauke Date: Wed, 1 Apr 2026 14:25:25 +0200 Subject: [PATCH 10/15] feat: recover secrets file on startup from jumpbox --- internal/bootstrap/gcp/install_config.go | 9 ++++++++- internal/installer/node/node.go | 16 +++++++--------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/internal/bootstrap/gcp/install_config.go b/internal/bootstrap/gcp/install_config.go index 674d7fa4..b8bd7bb1 100644 --- a/internal/bootstrap/gcp/install_config.go +++ b/internal/bootstrap/gcp/install_config.go @@ -45,8 +45,10 @@ func (b *GCPBootstrapper) EnsureInstallConfig() error { 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 { - // check if gcp project exists for recovery 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) @@ -63,6 +65,11 @@ func (b *GCPBootstrapper) recoverConfig() error { 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 } diff --git a/internal/installer/node/node.go b/internal/installer/node/node.go index f499243f..131e90cb 100644 --- a/internal/installer/node/node.go +++ b/internal/installer/node/node.go @@ -295,13 +295,11 @@ 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)) @@ -309,7 +307,7 @@ func (c *SSHNodeClient) CopyFile(n *Node, src string, dst string) error { 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 copies a file from the remote node to the local system via SFTP @@ -530,7 +528,7 @@ func (n *Node) downloadFile(jumpboxIp, ip, username, src, dst string) error { _, err = dstFile.ReadFrom(srcFile) if err != nil { - return fmt.Errorf("failed to copy data from %s to %s: %v", src, dst, err) + return fmt.Errorf("failed to download data from %s to %s: %v", src, dst, err) } return nil From c4fa150d6041e36de2aa2b2c069caed11ab9398b Mon Sep 17 00:00:00 2001 From: Jonas Kauke Date: Wed, 1 Apr 2026 14:40:23 +0200 Subject: [PATCH 11/15] chore: add comment --- internal/bootstrap/gcp/gce.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/bootstrap/gcp/gce.go b/internal/bootstrap/gcp/gce.go index 0c618898..c2b98a8a 100644 --- a/internal/bootstrap/gcp/gce.go +++ b/internal/bootstrap/gcp/gce.go @@ -380,6 +380,8 @@ 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 { From 8616a700dc264fbe58b4e86dd8b5ccb5f94a8816 Mon Sep 17 00:00:00 2001 From: Jonas Kauke Date: Wed, 1 Apr 2026 14:41:16 +0200 Subject: [PATCH 12/15] chore: cleanup --- internal/bootstrap/gcp/gce.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/bootstrap/gcp/gce.go b/internal/bootstrap/gcp/gce.go index c2b98a8a..3882d25b 100644 --- a/internal/bootstrap/gcp/gce.go +++ b/internal/bootstrap/gcp/gce.go @@ -388,12 +388,12 @@ func (b *GCPBootstrapper) GetNodeByName(name string) (*node.Node, error) { return nil, fmt.Errorf("failed to get instance %s: %w", name, err) } - internalIP, externalIP := ExtractInstanceIPs(existingInstance) - existingNode := &node.Node{ NodeClient: b.NodeClient, FileIO: b.fw, } + + internalIP, externalIP := ExtractInstanceIPs(existingInstance) existingNode.UpdateNode(name, externalIP, internalIP) return existingNode, nil From 3d7b829856be91a7da472f7c060c0c0ac0da86ff Mon Sep 17 00:00:00 2001 From: Jonas Kauke Date: Wed, 1 Apr 2026 14:44:26 +0200 Subject: [PATCH 13/15] chore: cleanup --- internal/bootstrap/gcp/install_config.go | 2 +- internal/installer/node/node.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/bootstrap/gcp/install_config.go b/internal/bootstrap/gcp/install_config.go index b8bd7bb1..5a7ba654 100644 --- a/internal/bootstrap/gcp/install_config.go +++ b/internal/bootstrap/gcp/install_config.go @@ -15,7 +15,7 @@ const ( remoteInstallConfigPath string = "/etc/codesphere/config.yaml" ) -// EnsureInstallConfig recovers the config either from the jumpbox or from a local file if desired. +// 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 { if b.fw.Exists(b.Env.InstallConfigPath) || b.Env.RecoverConfig { diff --git a/internal/installer/node/node.go b/internal/installer/node/node.go index 131e90cb..196b7eb2 100644 --- a/internal/installer/node/node.go +++ b/internal/installer/node/node.go @@ -310,7 +310,7 @@ func (c *SSHNodeClient) CopyFile(n *Node, src string, dst string) error { return n.copyFile(jumpBoxIP, nodeIP, "root", src, dst) } -// DownloadFile copies a file from the remote node to the local system via SFTP +// 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 @@ -506,7 +506,7 @@ func (n *Node) copyFile(jumpboxIp string, ip string, username string, src string return nil } -// downloadFile copies a file from the remote node to the local system via SFTP +// 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 { From 1dec14e29df4e62bb5cbe504eab92197b345500f Mon Sep 17 00:00:00 2001 From: Jonas Kauke Date: Thu, 2 Apr 2026 13:33:44 +0200 Subject: [PATCH 14/15] test: add recovery tests --- internal/bootstrap/gcp/install_config.go | 14 ++-- internal/bootstrap/gcp/install_config_test.go | 78 +++++++++++++++++++ 2 files changed, 85 insertions(+), 7 deletions(-) diff --git a/internal/bootstrap/gcp/install_config.go b/internal/bootstrap/gcp/install_config.go index 5a7ba654..288071e9 100644 --- a/internal/bootstrap/gcp/install_config.go +++ b/internal/bootstrap/gcp/install_config.go @@ -18,15 +18,15 @@ const ( // 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 { - if b.fw.Exists(b.Env.InstallConfigPath) || b.Env.RecoverConfig { - // recovery will overwrite local config - if b.Env.RecoverConfig { - err := b.recoverConfig() - if err != nil { - return fmt.Errorf("failed to recover config: %w", err) - } + // 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) diff --git a/internal/bootstrap/gcp/install_config_test.go b/internal/bootstrap/gcp/install_config_test.go index 342af6ca..5ea84d43 100644 --- a/internal/bootstrap/gcp/install_config_test.go +++ b/internal/bootstrap/gcp/install_config_test.go @@ -8,6 +8,7 @@ import ( "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" @@ -20,6 +21,8 @@ import ( . "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() { @@ -74,6 +77,7 @@ var _ = Describe("Installconfig & Secrets", func() { GitHubAppClientSecret: "fake-secret", InstallConfigPath: "fake-config-file", SecretsFilePath: "fake-secret", + RecoverConfig: false, ProjectName: "test-project", ProjectTTL: "1h", SecretsDir: "/etc/codesphere/secrets", @@ -124,6 +128,27 @@ var _ = Describe("Installconfig & Secrets", func() { 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() { @@ -146,6 +171,59 @@ var _ = Describe("Installconfig & Secrets", func() { 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")) + }) + }) + }) }) From ee73a28057ae6eb3b529a9bb5336739c791b4b7a Mon Sep 17 00:00:00 2001 From: joka134 <27293650+joka134@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:35:01 +0000 Subject: [PATCH 15/15] chore(docs): Auto-update docs and licenses Signed-off-by: joka134 <27293650+joka134@users.noreply.github.com> --- NOTICE | 30 ++++++++++++++++++------------ internal/tmpl/NOTICE | 30 ++++++++++++++++++------------ 2 files changed, 36 insertions(+), 24 deletions(-) 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/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