From e41298a8a049c858ac9719ffcd493689ea7f6841 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Thu, 9 Apr 2026 15:40:18 +0200 Subject: [PATCH 1/8] feat: implement certificate and key validation for PostgreSQL secrets generation --- internal/bootstrap/gcp/install_config.go | 36 +++-- internal/installer/config_manager.go | 2 +- internal/installer/config_manager_secrets.go | 8 + internal/installer/config_manager_test.go | 157 +++++++++++++++++++ internal/installer/crypto.go | 32 ++++ internal/installer/crypto_test.go | 46 ++++++ 6 files changed, 271 insertions(+), 10 deletions(-) diff --git a/internal/bootstrap/gcp/install_config.go b/internal/bootstrap/gcp/install_config.go index e5d02864..ef02ed48 100644 --- a/internal/bootstrap/gcp/install_config.go +++ b/internal/bootstrap/gcp/install_config.go @@ -113,6 +113,9 @@ func (b *GCPBootstrapper) UpdateInstallConfig() error { Hostname: b.Env.PostgreSQLNode.GetName(), } } + + previousPrimaryIP := b.Env.InstallConfig.Postgres.Primary.IP + previousPrimaryHostname := b.Env.InstallConfig.Postgres.Primary.Hostname b.Env.InstallConfig.Postgres.Primary.IP = b.Env.PostgreSQLNode.GetInternalIP() b.Env.InstallConfig.Ceph.CsiKubeletDir = "/var/lib/k0s/kubelet" @@ -247,16 +250,28 @@ func (b *GCPBootstrapper) UpdateInstallConfig() error { 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) + // Only regenerate postgres certificates if the IP or hostname changed, + // or if the private key was not loaded from the vault. + primaryNeedsRegen := b.Env.InstallConfig.Postgres.Primary.PrivateKey == "" || + previousPrimaryIP != b.Env.InstallConfig.Postgres.Primary.IP || + previousPrimaryHostname != b.Env.InstallConfig.Postgres.Primary.Hostname + + if primaryNeedsRegen { + 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 err := installer.ValidateCertKeyPair(b.Env.InstallConfig.Postgres.Primary.SSLConfig.ServerCertPem, b.Env.InstallConfig.Postgres.Primary.PrivateKey); err != nil { + return fmt.Errorf("primary PostgreSQL cert/key validation failed: %w", err) + } } - if b.Env.InstallConfig.Postgres.Replica != nil { + if b.Env.InstallConfig.Postgres.Replica != nil && (b.Env.InstallConfig.Postgres.ReplicaPrivateKey == "") { + var err error 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, @@ -265,6 +280,9 @@ func (b *GCPBootstrapper) UpdateInstallConfig() error { if err != nil { return fmt.Errorf("failed to generate replica server certificate: %w", err) } + if err := installer.ValidateCertKeyPair(b.Env.InstallConfig.Postgres.Replica.SSLConfig.ServerCertPem, b.Env.InstallConfig.Postgres.ReplicaPrivateKey); err != nil { + return fmt.Errorf("replica PostgreSQL cert/key validation failed: %w", err) + } } } diff --git a/internal/installer/config_manager.go b/internal/installer/config_manager.go index e8e88041..0f540680 100644 --- a/internal/installer/config_manager.go +++ b/internal/installer/config_manager.go @@ -242,7 +242,7 @@ func (g *InstallConfig) WriteVault(vaultPath string, withComments bool) error { return fmt.Errorf("no configuration provided - config is nil") } - vault := g.Config.AddSecretsToVault(g.GetVault()) + vault := g.Config.ExtractVault() vaultYAML, err := vault.Marshal() if err != nil { return fmt.Errorf("failed to marshal vault.yaml: %w", err) diff --git a/internal/installer/config_manager_secrets.go b/internal/installer/config_manager_secrets.go index 790538d5..887f41b8 100644 --- a/internal/installer/config_manager_secrets.go +++ b/internal/installer/config_manager_secrets.go @@ -59,6 +59,10 @@ func (g *InstallConfig) generatePostgresSecrets(config *files.RootConfig) error return fmt.Errorf("failed to generate primary PostgreSQL certificate: %w", err) } + if err := ValidateCertKeyPair(config.Postgres.Primary.SSLConfig.ServerCertPem, config.Postgres.Primary.PrivateKey); err != nil { + return fmt.Errorf("primary PostgreSQL cert/key validation failed: %w", err) + } + config.Postgres.AdminPassword = GeneratePassword(32) config.Postgres.ReplicaPassword = GeneratePassword(32) @@ -72,6 +76,10 @@ func (g *InstallConfig) generatePostgresSecrets(config *files.RootConfig) error if err != nil { return fmt.Errorf("failed to generate replica PostgreSQL certificate: %w", err) } + + if err := ValidateCertKeyPair(config.Postgres.Replica.SSLConfig.ServerCertPem, config.Postgres.ReplicaPrivateKey); err != nil { + return fmt.Errorf("replica PostgreSQL cert/key validation failed: %w", err) + } } services := []string{"auth", "deployment", "ide", "marketplace", "payment", "public_api", "team", "workspace"} diff --git a/internal/installer/config_manager_test.go b/internal/installer/config_manager_test.go index 7b1c6810..ce5010a3 100644 --- a/internal/installer/config_manager_test.go +++ b/internal/installer/config_manager_test.go @@ -85,6 +85,26 @@ func (m *MockFileIO) ReadDir(dirname string) ([]os.DirEntry, error) { return nil, nil } +func (m *MockFileIO) ReadFile(filename string) ([]byte, error) { + if data, ok := m.files[filename]; ok { + return data, nil + } + return nil, os.ErrNotExist +} + +func (m *MockFileIO) Remove(path string) error { + delete(m.files, path) + return nil +} + +func (m *MockFileIO) Chmod(name string, mode os.FileMode) error { + return nil +} + +func (m *MockFileIO) GetFileContent(path string) []byte { + return m.files[path] +} + type MockFile struct { *bytes.Buffer closed bool @@ -504,5 +524,142 @@ var _ = Describe("ConfigManager", func() { }) }) + Context("vault deduplication on re-write", func() { + It("should not produce duplicate vault entries when WriteVault is called after loading existing vault", func() { + err := configManager.ApplyProfile("prod") + Expect(err).ToNot(HaveOccurred()) + + err = configManager.GenerateSecrets() + Expect(err).ToNot(HaveOccurred()) + + // Simulate first write: extract vault from config + vault1 := configManager.Config.ExtractVault() + firstEntryCount := len(vault1.Secrets) + Expect(firstEntryCount).To(BeNumerically(">", 0)) + + // Simulate loading that vault back + configManager.Vault = vault1 + + // Write vault again + vault2 := configManager.Config.ExtractVault() + Expect(len(vault2.Secrets)).To(Equal(firstEntryCount), "vault should have same number of entries after re-write, no duplicates") + + // Verify no duplicate secret names + nameCount := make(map[string]int) + for _, secret := range vault2.Secrets { + nameCount[secret.Name]++ + } + for name, count := range nameCount { + Expect(count).To(Equal(1), "secret %s appears %d times, expected 1", name, count) + } + }) + }) + + Context("full bootstrap re-run simulation", func() { + It("should preserve matching cert/key pairs across write → load → merge → re-write cycle", func() { + mockIO := NewMockFileIO() + configManager.SetFileIO(mockIO) + + // --- First run: generate everything from scratch --- + err := configManager.ApplyProfile("prod") + Expect(err).ToNot(HaveOccurred()) + + err = configManager.GenerateSecrets() + Expect(err).ToNot(HaveOccurred()) + + // Verify cert/key pair matches after initial generation + err = installer.ValidateCertKeyPair( + configManager.Config.Postgres.Primary.SSLConfig.ServerCertPem, + configManager.Config.Postgres.Primary.PrivateKey, + ) + Expect(err).ToNot(HaveOccurred(), "cert/key should match after initial generation") + + // Save the original cert and key for later comparison + origCert := configManager.Config.Postgres.Primary.SSLConfig.ServerCertPem + origKey := configManager.Config.Postgres.Primary.PrivateKey + Expect(origCert).ToNot(BeEmpty()) + Expect(origKey).ToNot(BeEmpty()) + + // Write config and vault + err = configManager.WriteInstallConfig("/tmp/config.yaml", false) + Expect(err).ToNot(HaveOccurred()) + err = configManager.WriteVault("/tmp/vault.yaml", false) + Expect(err).ToNot(HaveOccurred()) + + // --- Second run: simulate loading existing files --- + configManager2 := &installer.InstallConfig{} + configManager2.SetFileIO(mockIO) + + // Reload config from written YAML + configBytes := mockIO.GetFileContent("/tmp/config.yaml") + Expect(configBytes).ToNot(BeNil()) + config2 := files.NewRootConfig() + err = config2.Unmarshal(configBytes) + Expect(err).ToNot(HaveOccurred()) + configManager2.Config = &config2 + + Expect(configManager2.Config.Postgres.Primary.PrivateKey).To(BeEmpty(), + "private key should NOT be in config.yaml (it has yaml:\"-\" tag)") + Expect(configManager2.Config.Postgres.Primary.SSLConfig.ServerCertPem).To(Equal(origCert), + "cert should be in config.yaml") + + // Reload vault from written YAML + vaultBytes := mockIO.GetFileContent("/tmp/vault.yaml") + Expect(vaultBytes).ToNot(BeNil()) + vault2 := &files.InstallVault{} + err = vault2.Unmarshal(vaultBytes) + Expect(err).ToNot(HaveOccurred()) + configManager2.Vault = vault2 + + // Merge vault into config + err = configManager2.MergeVaultIntoConfig() + Expect(err).ToNot(HaveOccurred()) + + // After merge, the private key should be restored from vault + Expect(configManager2.Config.Postgres.Primary.PrivateKey).To(Equal(origKey), + "private key should be restored from vault after merge") + + // Cert/key should still match + err = installer.ValidateCertKeyPair( + configManager2.Config.Postgres.Primary.SSLConfig.ServerCertPem, + configManager2.Config.Postgres.Primary.PrivateKey, + ) + Expect(err).ToNot(HaveOccurred(), "cert/key should match after load + merge") + + // Write vault again + err = configManager2.WriteVault("/tmp/vault2.yaml", false) + Expect(err).ToNot(HaveOccurred()) + + // Verify no duplicates in re-written vault + vault3 := &files.InstallVault{} + vaultBytes2 := mockIO.GetFileContent("/tmp/vault2.yaml") + err = vault3.Unmarshal(vaultBytes2) + Expect(err).ToNot(HaveOccurred()) + + nameCount := make(map[string]int) + for _, secret := range vault3.Secrets { + nameCount[secret.Name]++ + } + for name, count := range nameCount { + Expect(count).To(Equal(1), "secret '%s' has %d entries (expected 1) — duplication bug!", name, count) + } + + // Verify the key in re-written vault still matches the cert + var rewrittenKey string + for _, secret := range vault3.Secrets { + if secret.Name == "postgresPrimaryServerKeyPem" && secret.File != nil { + rewrittenKey = secret.File.Content + } + } + Expect(rewrittenKey).To(Equal(origKey), + "re-written vault should contain the same key") + err = installer.ValidateCertKeyPair( + configManager2.Config.Postgres.Primary.SSLConfig.ServerCertPem, + rewrittenKey, + ) + Expect(err).ToNot(HaveOccurred(), "cert/key should match in re-written vault") + }) + }) + }) }) diff --git a/internal/installer/crypto.go b/internal/installer/crypto.go index 0fbe829f..6e8eea23 100644 --- a/internal/installer/crypto.go +++ b/internal/installer/crypto.go @@ -219,3 +219,35 @@ func encodePEMCert(certDER []byte) string { Bytes: certDER, })) } + +// ValidateCertKeyPair verifies that a PEM-encoded certificate's public key matches a PEM-encoded private key. +func ValidateCertKeyPair(certPEM, keyPEM string) error { + certBlock, _ := pem.Decode([]byte(certPEM)) + if certBlock == nil { + return fmt.Errorf("failed to decode certificate PEM") + } + cert, err := x509.ParseCertificate(certBlock.Bytes) + if err != nil { + return fmt.Errorf("failed to parse certificate: %w", err) + } + + keyBlock, _ := pem.Decode([]byte(keyPEM)) + if keyBlock == nil { + return fmt.Errorf("failed to decode private key PEM") + } + privKey, err := x509.ParsePKCS1PrivateKey(keyBlock.Bytes) + if err != nil { + return fmt.Errorf("failed to parse private key: %w", err) + } + + pubKey, ok := cert.PublicKey.(*rsa.PublicKey) + if !ok { + return fmt.Errorf("certificate does not contain an RSA public key") + } + + if pubKey.N.Cmp(privKey.N) != 0 || pubKey.E != privKey.E { + return fmt.Errorf("certificate and private key do not match") + } + + return nil +} diff --git a/internal/installer/crypto_test.go b/internal/installer/crypto_test.go index faeaa69e..bff9ad62 100644 --- a/internal/installer/crypto_test.go +++ b/internal/installer/crypto_test.go @@ -107,3 +107,49 @@ var _ = Describe("GeneratePassword", func() { Expect(password1).NotTo(Equal(password2)) }) }) + +var _ = Describe("ValidateCertKeyPair", func() { + It("succeeds for a matching cert and key pair", func() { + caKeyPEM, caCertPEM, err := GenerateCA("Test CA", "DE", "Berlin", "TestOrg") + Expect(err).NotTo(HaveOccurred()) + + keyPEM, certPEM, err := GenerateServerCertificate(caKeyPEM, caCertPEM, "test-server", []string{"10.0.0.1"}) + Expect(err).NotTo(HaveOccurred()) + + err = ValidateCertKeyPair(certPEM, keyPEM) + Expect(err).NotTo(HaveOccurred()) + }) + + It("fails for a mismatched cert and key pair", func() { + caKeyPEM, caCertPEM, err := GenerateCA("Test CA", "DE", "Berlin", "TestOrg") + Expect(err).NotTo(HaveOccurred()) + + _, certPEM, err := GenerateServerCertificate(caKeyPEM, caCertPEM, "server-1", []string{"10.0.0.1"}) + Expect(err).NotTo(HaveOccurred()) + + keyPEM, _, err := GenerateServerCertificate(caKeyPEM, caCertPEM, "server-2", []string{"10.0.0.2"}) + Expect(err).NotTo(HaveOccurred()) + + err = ValidateCertKeyPair(certPEM, keyPEM) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("do not match")) + }) + + It("fails for invalid cert PEM", func() { + err := ValidateCertKeyPair("not-a-cert", "not-a-key") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to decode certificate PEM")) + }) + + It("fails for invalid key PEM", func() { + caKeyPEM, caCertPEM, err := GenerateCA("Test CA", "DE", "Berlin", "TestOrg") + Expect(err).NotTo(HaveOccurred()) + + _, certPEM, err := GenerateServerCertificate(caKeyPEM, caCertPEM, "test-server", []string{"10.0.0.1"}) + Expect(err).NotTo(HaveOccurred()) + + err = ValidateCertKeyPair(certPEM, "not-a-key") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to decode private key PEM")) + }) +}) From 1d344e1017a10cabc9689ce5564ce9c8ead9cca8 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Thu, 9 Apr 2026 16:49:20 +0200 Subject: [PATCH 2/8] feat: enhance PostgreSQL certificate management and update validation logic --- internal/bootstrap/gcp/install_config.go | 1 + internal/bootstrap/gcp/install_config_test.go | 125 ++++++++++++++++++ internal/installer/crypto.go | 31 +---- internal/installer/crypto_test.go | 6 +- 4 files changed, 132 insertions(+), 31 deletions(-) diff --git a/internal/bootstrap/gcp/install_config.go b/internal/bootstrap/gcp/install_config.go index ef02ed48..8b0ab36d 100644 --- a/internal/bootstrap/gcp/install_config.go +++ b/internal/bootstrap/gcp/install_config.go @@ -270,6 +270,7 @@ func (b *GCPBootstrapper) UpdateInstallConfig() error { return fmt.Errorf("primary PostgreSQL cert/key validation failed: %w", err) } } + // Replica certificates only regenerate if the key is missing from the vault. if b.Env.InstallConfig.Postgres.Replica != nil && (b.Env.InstallConfig.Postgres.ReplicaPrivateKey == "") { var err error b.Env.InstallConfig.Postgres.ReplicaPrivateKey, b.Env.InstallConfig.Postgres.Replica.SSLConfig.ServerCertPem, err = installer.GenerateServerCertificate( diff --git a/internal/bootstrap/gcp/install_config_test.go b/internal/bootstrap/gcp/install_config_test.go index 9e30dbcd..8c07cdaa 100644 --- a/internal/bootstrap/gcp/install_config_test.go +++ b/internal/bootstrap/gcp/install_config_test.go @@ -473,6 +473,131 @@ var _ = Describe("Installconfig & Secrets", func() { Expect(err.Error()).To(ContainSubstring("failed to copy secrets file to jumpbox")) }) }) + + Describe("ExistingConfigUsed", func() { + BeforeEach(func() { + csEnv.ExistingConfigUsed = true + }) + + Context("with unchanged IP and existing key", func() { + BeforeEach(func() { + caKey, caCert, err := installer.GenerateCA("Test CA", "DE", "Berlin", "TestOrg") + Expect(err).NotTo(HaveOccurred()) + + key, cert, err := installer.GenerateServerCertificate(caKey, caCert, "postgres", []string{"10.0.0.1"}) + Expect(err).NotTo(HaveOccurred()) + + csEnv.InstallConfig.Postgres.CaCertPrivateKey = caKey + csEnv.InstallConfig.Postgres.CACertPem = caCert + csEnv.InstallConfig.Postgres.Primary.IP = "10.0.0.1" + csEnv.InstallConfig.Postgres.Primary.Hostname = "postgres" + csEnv.InstallConfig.Postgres.Primary.PrivateKey = key + csEnv.InstallConfig.Postgres.Primary.SSLConfig.ServerCertPem = cert + }) + + It("preserves existing cert/key without regeneration", func() { + origKey := csEnv.InstallConfig.Postgres.Primary.PrivateKey + origCert := csEnv.InstallConfig.Postgres.Primary.SSLConfig.ServerCertPem + + // GenerateSecrets should NOT be called + 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.Postgres.Primary.PrivateKey).To(Equal(origKey)) + Expect(bs.Env.InstallConfig.Postgres.Primary.SSLConfig.ServerCertPem).To(Equal(origCert)) + }) + }) + + Context("with changed IP", func() { + BeforeEach(func() { + caKey, caCert, err := installer.GenerateCA("Test CA", "DE", "Berlin", "TestOrg") + Expect(err).NotTo(HaveOccurred()) + + key, cert, err := installer.GenerateServerCertificate(caKey, caCert, "postgres", []string{"10.0.0.99"}) + Expect(err).NotTo(HaveOccurred()) + + csEnv.InstallConfig.Postgres.CaCertPrivateKey = caKey + csEnv.InstallConfig.Postgres.CACertPem = caCert + csEnv.InstallConfig.Postgres.Primary.IP = "10.0.0.99" + csEnv.InstallConfig.Postgres.Primary.Hostname = "postgres" + csEnv.InstallConfig.Postgres.Primary.PrivateKey = key + csEnv.InstallConfig.Postgres.Primary.SSLConfig.ServerCertPem = cert + }) + + It("regenerates cert/key for the new IP", func() { + origKey := csEnv.InstallConfig.Postgres.Primary.PrivateKey + + 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()) + + // IP should be updated to the node's IP + Expect(bs.Env.InstallConfig.Postgres.Primary.IP).To(Equal("10.0.0.1")) + // Key should be regenerated + Expect(bs.Env.InstallConfig.Postgres.Primary.PrivateKey).NotTo(Equal(origKey)) + Expect(bs.Env.InstallConfig.Postgres.Primary.PrivateKey).NotTo(BeEmpty()) + // New cert/key should match + err = installer.ValidateCertKeyPair( + bs.Env.InstallConfig.Postgres.Primary.SSLConfig.ServerCertPem, + bs.Env.InstallConfig.Postgres.Primary.PrivateKey, + ) + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Context("with empty PrivateKey (not loaded from vault)", func() { + BeforeEach(func() { + caKey, caCert, err := installer.GenerateCA("Test CA", "DE", "Berlin", "TestOrg") + Expect(err).NotTo(HaveOccurred()) + + csEnv.InstallConfig.Postgres.CaCertPrivateKey = caKey + csEnv.InstallConfig.Postgres.CACertPem = caCert + csEnv.InstallConfig.Postgres.Primary.IP = "10.0.0.1" + csEnv.InstallConfig.Postgres.Primary.Hostname = "postgres" + csEnv.InstallConfig.Postgres.Primary.PrivateKey = "" + }) + + It("generates new cert/key pair", func() { + 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.Postgres.Primary.PrivateKey).NotTo(BeEmpty()) + Expect(bs.Env.InstallConfig.Postgres.Primary.SSLConfig.ServerCertPem).NotTo(BeEmpty()) + err = installer.ValidateCertKeyPair( + bs.Env.InstallConfig.Postgres.Primary.SSLConfig.ServerCertPem, + bs.Env.InstallConfig.Postgres.Primary.PrivateKey, + ) + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Context("with missing CA cert (cert generation fails)", func() { + BeforeEach(func() { + csEnv.InstallConfig.Postgres.CaCertPrivateKey = "" + csEnv.InstallConfig.Postgres.CACertPem = "" + csEnv.InstallConfig.Postgres.Primary.IP = "10.0.0.1" + csEnv.InstallConfig.Postgres.Primary.Hostname = "postgres" + csEnv.InstallConfig.Postgres.Primary.PrivateKey = "" + }) + + It("returns an error", func() { + err := bs.UpdateInstallConfig() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to generate primary server certificate")) + }) + }) + }) }) Describe("EnsureAgeKey", func() { diff --git a/internal/installer/crypto.go b/internal/installer/crypto.go index 6e8eea23..e85a9cc7 100644 --- a/internal/installer/crypto.go +++ b/internal/installer/crypto.go @@ -8,6 +8,7 @@ import ( "crypto/elliptic" "crypto/rand" "crypto/rsa" + "crypto/tls" "crypto/x509" "crypto/x509/pkix" "encoding/base64" @@ -222,32 +223,6 @@ func encodePEMCert(certDER []byte) string { // ValidateCertKeyPair verifies that a PEM-encoded certificate's public key matches a PEM-encoded private key. func ValidateCertKeyPair(certPEM, keyPEM string) error { - certBlock, _ := pem.Decode([]byte(certPEM)) - if certBlock == nil { - return fmt.Errorf("failed to decode certificate PEM") - } - cert, err := x509.ParseCertificate(certBlock.Bytes) - if err != nil { - return fmt.Errorf("failed to parse certificate: %w", err) - } - - keyBlock, _ := pem.Decode([]byte(keyPEM)) - if keyBlock == nil { - return fmt.Errorf("failed to decode private key PEM") - } - privKey, err := x509.ParsePKCS1PrivateKey(keyBlock.Bytes) - if err != nil { - return fmt.Errorf("failed to parse private key: %w", err) - } - - pubKey, ok := cert.PublicKey.(*rsa.PublicKey) - if !ok { - return fmt.Errorf("certificate does not contain an RSA public key") - } - - if pubKey.N.Cmp(privKey.N) != 0 || pubKey.E != privKey.E { - return fmt.Errorf("certificate and private key do not match") - } - - return nil + _, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM)) + return err } diff --git a/internal/installer/crypto_test.go b/internal/installer/crypto_test.go index bff9ad62..58e159f1 100644 --- a/internal/installer/crypto_test.go +++ b/internal/installer/crypto_test.go @@ -132,13 +132,13 @@ var _ = Describe("ValidateCertKeyPair", func() { err = ValidateCertKeyPair(certPEM, keyPEM) Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("do not match")) + Expect(err.Error()).To(ContainSubstring("private key does not match public key")) }) It("fails for invalid cert PEM", func() { err := ValidateCertKeyPair("not-a-cert", "not-a-key") Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to decode certificate PEM")) + Expect(err.Error()).To(ContainSubstring("failed to find any PEM data in certificate input")) }) It("fails for invalid key PEM", func() { @@ -150,6 +150,6 @@ var _ = Describe("ValidateCertKeyPair", func() { err = ValidateCertKeyPair(certPEM, "not-a-key") Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to decode private key PEM")) + Expect(err.Error()).To(ContainSubstring("failed to find any PEM data in key input")) }) }) From 094ace13680bb86c07e0827112a1225c0dc193e2 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:50:53 +0000 Subject: [PATCH 3/8] chore(docs): Auto-update docs and licenses Signed-off-by: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> --- NOTICE | 24 ++++++++++++------------ internal/tmpl/NOTICE | 24 ++++++++++++------------ 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/NOTICE b/NOTICE index fa54b995..10ac6109 100644 --- a/NOTICE +++ b/NOTICE @@ -23,9 +23,9 @@ License URL: https://github.com/googleapis/google-cloud-go/blob/auth/oauth2adapt ---------- Module: cloud.google.com/go/compute -Version: v1.58.0 +Version: v1.59.0 License: Apache-2.0 -License URL: https://github.com/googleapis/google-cloud-go/blob/compute/v1.58.0/compute/LICENSE +License URL: https://github.com/googleapis/google-cloud-go/blob/compute/v1.59.0/compute/LICENSE ---------- Module: cloud.google.com/go/compute/metadata @@ -1115,9 +1115,9 @@ License URL: https://cs.opensource.google/go/x/crypto/+/v0.49.0:LICENSE ---------- Module: golang.org/x/mod/semver -Version: v0.34.0 +Version: v0.35.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/mod/+/v0.34.0:LICENSE +License URL: https://cs.opensource.google/go/x/mod/+/v0.35.0:LICENSE ---------- Module: golang.org/x/net @@ -1139,15 +1139,15 @@ License URL: https://cs.opensource.google/go/x/sync/+/v0.20.0:LICENSE ---------- Module: golang.org/x/sys -Version: v0.42.0 +Version: v0.43.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/sys/+/v0.42.0:LICENSE +License URL: https://cs.opensource.google/go/x/sys/+/v0.43.0:LICENSE ---------- Module: golang.org/x/term -Version: v0.41.0 +Version: v0.42.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/term/+/v0.41.0:LICENSE +License URL: https://cs.opensource.google/go/x/term/+/v0.42.0:LICENSE ---------- Module: golang.org/x/text @@ -1187,9 +1187,9 @@ License URL: https://github.com/googleapis/go-genproto/blob/d00831a3d3e7/LICENSE ---------- Module: google.golang.org/genproto/googleapis/api -Version: v0.0.0-20260401001100-f93e5f3e9f0f +Version: v0.0.0-20260401024825-9d38bb4040a9 License: Apache-2.0 -License URL: https://github.com/googleapis/go-genproto/blob/f93e5f3e9f0f/googleapis/api/LICENSE +License URL: https://github.com/googleapis/go-genproto/blob/9d38bb4040a9/googleapis/api/LICENSE ---------- Module: google.golang.org/genproto/googleapis/rpc @@ -1235,9 +1235,9 @@ License URL: https://github.com/go-yaml/yaml/blob/v3.0.1/LICENSE ---------- Module: helm.sh/helm/v4 -Version: v4.1.3 +Version: v4.1.4 License: Apache-2.0 -License URL: https://github.com/helm/helm/blob/v4.1.3/LICENSE +License URL: https://github.com/helm/helm/blob/v4.1.4/LICENSE ---------- Module: k8s.io/api diff --git a/internal/tmpl/NOTICE b/internal/tmpl/NOTICE index fa54b995..10ac6109 100644 --- a/internal/tmpl/NOTICE +++ b/internal/tmpl/NOTICE @@ -23,9 +23,9 @@ License URL: https://github.com/googleapis/google-cloud-go/blob/auth/oauth2adapt ---------- Module: cloud.google.com/go/compute -Version: v1.58.0 +Version: v1.59.0 License: Apache-2.0 -License URL: https://github.com/googleapis/google-cloud-go/blob/compute/v1.58.0/compute/LICENSE +License URL: https://github.com/googleapis/google-cloud-go/blob/compute/v1.59.0/compute/LICENSE ---------- Module: cloud.google.com/go/compute/metadata @@ -1115,9 +1115,9 @@ License URL: https://cs.opensource.google/go/x/crypto/+/v0.49.0:LICENSE ---------- Module: golang.org/x/mod/semver -Version: v0.34.0 +Version: v0.35.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/mod/+/v0.34.0:LICENSE +License URL: https://cs.opensource.google/go/x/mod/+/v0.35.0:LICENSE ---------- Module: golang.org/x/net @@ -1139,15 +1139,15 @@ License URL: https://cs.opensource.google/go/x/sync/+/v0.20.0:LICENSE ---------- Module: golang.org/x/sys -Version: v0.42.0 +Version: v0.43.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/sys/+/v0.42.0:LICENSE +License URL: https://cs.opensource.google/go/x/sys/+/v0.43.0:LICENSE ---------- Module: golang.org/x/term -Version: v0.41.0 +Version: v0.42.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/term/+/v0.41.0:LICENSE +License URL: https://cs.opensource.google/go/x/term/+/v0.42.0:LICENSE ---------- Module: golang.org/x/text @@ -1187,9 +1187,9 @@ License URL: https://github.com/googleapis/go-genproto/blob/d00831a3d3e7/LICENSE ---------- Module: google.golang.org/genproto/googleapis/api -Version: v0.0.0-20260401001100-f93e5f3e9f0f +Version: v0.0.0-20260401024825-9d38bb4040a9 License: Apache-2.0 -License URL: https://github.com/googleapis/go-genproto/blob/f93e5f3e9f0f/googleapis/api/LICENSE +License URL: https://github.com/googleapis/go-genproto/blob/9d38bb4040a9/googleapis/api/LICENSE ---------- Module: google.golang.org/genproto/googleapis/rpc @@ -1235,9 +1235,9 @@ License URL: https://github.com/go-yaml/yaml/blob/v3.0.1/LICENSE ---------- Module: helm.sh/helm/v4 -Version: v4.1.3 +Version: v4.1.4 License: Apache-2.0 -License URL: https://github.com/helm/helm/blob/v4.1.3/LICENSE +License URL: https://github.com/helm/helm/blob/v4.1.4/LICENSE ---------- Module: k8s.io/api From f614735ca3a2846f99dbe197c59a3645ee7374fc Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:42:18 +0200 Subject: [PATCH 4/8] test: refine error messages for invalid PEM data in certificate validation --- internal/installer/config_manager_test.go | 37 ++++++++++++----------- internal/installer/crypto_test.go | 4 +-- 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/internal/installer/config_manager_test.go b/internal/installer/config_manager_test.go index ce5010a3..f78d008d 100644 --- a/internal/installer/config_manager_test.go +++ b/internal/installer/config_manager_test.go @@ -526,32 +526,35 @@ var _ = Describe("ConfigManager", func() { Context("vault deduplication on re-write", func() { It("should not produce duplicate vault entries when WriteVault is called after loading existing vault", func() { + mockIO := NewMockFileIO() + configManager.SetFileIO(mockIO) + err := configManager.ApplyProfile("prod") Expect(err).ToNot(HaveOccurred()) err = configManager.GenerateSecrets() Expect(err).ToNot(HaveOccurred()) - // Simulate first write: extract vault from config - vault1 := configManager.Config.ExtractVault() - firstEntryCount := len(vault1.Secrets) - Expect(firstEntryCount).To(BeNumerically(">", 0)) + // First write via WriteVault + err = configManager.WriteVault("/tmp/vault.yaml", false) + Expect(err).ToNot(HaveOccurred()) - // Simulate loading that vault back - configManager.Vault = vault1 + firstVaultBytes := mockIO.GetFileContent("/tmp/vault.yaml") + Expect(firstVaultBytes).ToNot(BeEmpty()) - // Write vault again - vault2 := configManager.Config.ExtractVault() - Expect(len(vault2.Secrets)).To(Equal(firstEntryCount), "vault should have same number of entries after re-write, no duplicates") + // Load the written vault back + vault := &files.InstallVault{} + err = vault.Unmarshal(firstVaultBytes) + Expect(err).ToNot(HaveOccurred()) + configManager.Vault = vault - // Verify no duplicate secret names - nameCount := make(map[string]int) - for _, secret := range vault2.Secrets { - nameCount[secret.Name]++ - } - for name, count := range nameCount { - Expect(count).To(Equal(1), "secret %s appears %d times, expected 1", name, count) - } + // Re-write vault (simulating a second run) + err = configManager.WriteVault("/tmp/vault.yaml", false) + Expect(err).ToNot(HaveOccurred()) + + secondVaultBytes := mockIO.GetFileContent("/tmp/vault.yaml") + Expect(secondVaultBytes).To(Equal(firstVaultBytes), + "serialized vault should be identical after load and re-write") }) }) diff --git a/internal/installer/crypto_test.go b/internal/installer/crypto_test.go index 58e159f1..5649ddd9 100644 --- a/internal/installer/crypto_test.go +++ b/internal/installer/crypto_test.go @@ -138,7 +138,7 @@ var _ = Describe("ValidateCertKeyPair", func() { It("fails for invalid cert PEM", func() { err := ValidateCertKeyPair("not-a-cert", "not-a-key") Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to find any PEM data in certificate input")) + Expect(err.Error()).To(ContainSubstring("failed to find any PEM data")) }) It("fails for invalid key PEM", func() { @@ -150,6 +150,6 @@ var _ = Describe("ValidateCertKeyPair", func() { err = ValidateCertKeyPair(certPEM, "not-a-key") Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to find any PEM data in key input")) + Expect(err.Error()).To(ContainSubstring("failed to find any PEM data")) }) }) From e19628ecf2ef27076946612370155e09910ed961 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:10:45 +0200 Subject: [PATCH 5/8] feat: update PostgreSQL hostname in install config and preserve vault-only secrets in WriteVault --- internal/bootstrap/gcp/install_config.go | 1 + internal/installer/config_manager.go | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/internal/bootstrap/gcp/install_config.go b/internal/bootstrap/gcp/install_config.go index 8b0ab36d..13da2282 100644 --- a/internal/bootstrap/gcp/install_config.go +++ b/internal/bootstrap/gcp/install_config.go @@ -117,6 +117,7 @@ func (b *GCPBootstrapper) UpdateInstallConfig() error { previousPrimaryIP := b.Env.InstallConfig.Postgres.Primary.IP previousPrimaryHostname := b.Env.InstallConfig.Postgres.Primary.Hostname b.Env.InstallConfig.Postgres.Primary.IP = b.Env.PostgreSQLNode.GetInternalIP() + b.Env.InstallConfig.Postgres.Primary.Hostname = b.Env.PostgreSQLNode.GetName() b.Env.InstallConfig.Ceph.CsiKubeletDir = "/var/lib/k0s/kubelet" b.Env.InstallConfig.Ceph.NodesSubnet = "10.10.0.0/20" diff --git a/internal/installer/config_manager.go b/internal/installer/config_manager.go index 0f540680..297a29e1 100644 --- a/internal/installer/config_manager.go +++ b/internal/installer/config_manager.go @@ -243,6 +243,20 @@ func (g *InstallConfig) WriteVault(vaultPath string, withComments bool) error { } vault := g.Config.ExtractVault() + + // Preserve vault-only secrets that were added directly via SetSecret and aren't on the config struct. + if g.Vault != nil { + configSecretNames := make(map[string]bool) + for _, s := range vault.Secrets { + configSecretNames[s.Name] = true + } + for _, s := range g.Vault.Secrets { + if !configSecretNames[s.Name] { + vault.Secrets = append(vault.Secrets, s) + } + } + } + vaultYAML, err := vault.Marshal() if err != nil { return fmt.Errorf("failed to marshal vault.yaml: %w", err) From 043cae98afa9f5e86aec444fe7a5041c9ba0c84d Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Fri, 10 Apr 2026 08:12:01 +0000 Subject: [PATCH 6/8] chore(docs): Auto-update docs and licenses Signed-off-by: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> --- NOTICE | 24 ++++++++++++------------ internal/tmpl/NOTICE | 24 ++++++++++++------------ 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/NOTICE b/NOTICE index 10ac6109..a770673a 100644 --- a/NOTICE +++ b/NOTICE @@ -5,9 +5,9 @@ This project includes code licensed under the following terms: ---------- Module: cloud.google.com/go/artifactregistry -Version: v1.21.0 +Version: v1.22.0 License: Apache-2.0 -License URL: https://github.com/googleapis/google-cloud-go/blob/artifactregistry/v1.21.0/artifactregistry/LICENSE +License URL: https://github.com/googleapis/google-cloud-go/blob/artifactregistry/v1.22.0/artifactregistry/LICENSE ---------- Module: cloud.google.com/go/auth @@ -35,21 +35,21 @@ License URL: https://github.com/googleapis/google-cloud-go/blob/compute/metadata ---------- Module: cloud.google.com/go/iam/apiv1/iampb -Version: v1.7.0 +Version: v1.8.0 License: Apache-2.0 -License URL: https://github.com/googleapis/google-cloud-go/blob/iam/v1.7.0/iam/LICENSE +License URL: https://github.com/googleapis/google-cloud-go/blob/iam/v1.8.0/iam/LICENSE ---------- Module: cloud.google.com/go/longrunning -Version: v0.8.0 +Version: v0.9.0 License: Apache-2.0 -License URL: https://github.com/googleapis/google-cloud-go/blob/longrunning/v0.8.0/longrunning/LICENSE +License URL: https://github.com/googleapis/google-cloud-go/blob/longrunning/v0.9.0/longrunning/LICENSE ---------- Module: cloud.google.com/go/resourcemanager -Version: v1.11.0 +Version: v1.12.0 License: Apache-2.0 -License URL: https://github.com/googleapis/google-cloud-go/blob/resourcemanager/v1.11.0/resourcemanager/LICENSE +License URL: https://github.com/googleapis/google-cloud-go/blob/resourcemanager/v1.12.0/resourcemanager/LICENSE ---------- Module: cloud.google.com/go/serviceusage @@ -1109,9 +1109,9 @@ License URL: https://github.com/yaml/go-yaml/blob/v3.0.4/LICENSE ---------- Module: golang.org/x/crypto -Version: v0.49.0 +Version: v0.50.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/crypto/+/v0.49.0:LICENSE +License URL: https://cs.opensource.google/go/x/crypto/+/v0.50.0:LICENSE ---------- Module: golang.org/x/mod/semver @@ -1151,9 +1151,9 @@ License URL: https://cs.opensource.google/go/x/term/+/v0.42.0:LICENSE ---------- Module: golang.org/x/text -Version: v0.35.0 +Version: v0.36.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/text/+/v0.35.0:LICENSE +License URL: https://cs.opensource.google/go/x/text/+/v0.36.0:LICENSE ---------- Module: golang.org/x/time/rate diff --git a/internal/tmpl/NOTICE b/internal/tmpl/NOTICE index 10ac6109..a770673a 100644 --- a/internal/tmpl/NOTICE +++ b/internal/tmpl/NOTICE @@ -5,9 +5,9 @@ This project includes code licensed under the following terms: ---------- Module: cloud.google.com/go/artifactregistry -Version: v1.21.0 +Version: v1.22.0 License: Apache-2.0 -License URL: https://github.com/googleapis/google-cloud-go/blob/artifactregistry/v1.21.0/artifactregistry/LICENSE +License URL: https://github.com/googleapis/google-cloud-go/blob/artifactregistry/v1.22.0/artifactregistry/LICENSE ---------- Module: cloud.google.com/go/auth @@ -35,21 +35,21 @@ License URL: https://github.com/googleapis/google-cloud-go/blob/compute/metadata ---------- Module: cloud.google.com/go/iam/apiv1/iampb -Version: v1.7.0 +Version: v1.8.0 License: Apache-2.0 -License URL: https://github.com/googleapis/google-cloud-go/blob/iam/v1.7.0/iam/LICENSE +License URL: https://github.com/googleapis/google-cloud-go/blob/iam/v1.8.0/iam/LICENSE ---------- Module: cloud.google.com/go/longrunning -Version: v0.8.0 +Version: v0.9.0 License: Apache-2.0 -License URL: https://github.com/googleapis/google-cloud-go/blob/longrunning/v0.8.0/longrunning/LICENSE +License URL: https://github.com/googleapis/google-cloud-go/blob/longrunning/v0.9.0/longrunning/LICENSE ---------- Module: cloud.google.com/go/resourcemanager -Version: v1.11.0 +Version: v1.12.0 License: Apache-2.0 -License URL: https://github.com/googleapis/google-cloud-go/blob/resourcemanager/v1.11.0/resourcemanager/LICENSE +License URL: https://github.com/googleapis/google-cloud-go/blob/resourcemanager/v1.12.0/resourcemanager/LICENSE ---------- Module: cloud.google.com/go/serviceusage @@ -1109,9 +1109,9 @@ License URL: https://github.com/yaml/go-yaml/blob/v3.0.4/LICENSE ---------- Module: golang.org/x/crypto -Version: v0.49.0 +Version: v0.50.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/crypto/+/v0.49.0:LICENSE +License URL: https://cs.opensource.google/go/x/crypto/+/v0.50.0:LICENSE ---------- Module: golang.org/x/mod/semver @@ -1151,9 +1151,9 @@ License URL: https://cs.opensource.google/go/x/term/+/v0.42.0:LICENSE ---------- Module: golang.org/x/text -Version: v0.35.0 +Version: v0.36.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/text/+/v0.35.0:LICENSE +License URL: https://cs.opensource.google/go/x/text/+/v0.36.0:LICENSE ---------- Module: golang.org/x/time/rate From 36610899c654b60b15efec7e7015ad91e5db16c0 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:27:50 +0200 Subject: [PATCH 7/8] ref: extract PostgreSQL certificate regeneration logic into a separate function --- internal/bootstrap/gcp/install_config.go | 77 +++++++++++-------- internal/bootstrap/gcp/install_config_test.go | 4 +- 2 files changed, 45 insertions(+), 36 deletions(-) diff --git a/internal/bootstrap/gcp/install_config.go b/internal/bootstrap/gcp/install_config.go index 13da2282..cec7f0fe 100644 --- a/internal/bootstrap/gcp/install_config.go +++ b/internal/bootstrap/gcp/install_config.go @@ -251,40 +251,8 @@ func (b *GCPBootstrapper) UpdateInstallConfig() error { return fmt.Errorf("failed to generate secrets: %w", err) } } else { - // Only regenerate postgres certificates if the IP or hostname changed, - // or if the private key was not loaded from the vault. - primaryNeedsRegen := b.Env.InstallConfig.Postgres.Primary.PrivateKey == "" || - previousPrimaryIP != b.Env.InstallConfig.Postgres.Primary.IP || - previousPrimaryHostname != b.Env.InstallConfig.Postgres.Primary.Hostname - - if primaryNeedsRegen { - 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 err := installer.ValidateCertKeyPair(b.Env.InstallConfig.Postgres.Primary.SSLConfig.ServerCertPem, b.Env.InstallConfig.Postgres.Primary.PrivateKey); err != nil { - return fmt.Errorf("primary PostgreSQL cert/key validation failed: %w", err) - } - } - // Replica certificates only regenerate if the key is missing from the vault. - if b.Env.InstallConfig.Postgres.Replica != nil && (b.Env.InstallConfig.Postgres.ReplicaPrivateKey == "") { - var err error - 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 err := installer.ValidateCertKeyPair(b.Env.InstallConfig.Postgres.Replica.SSLConfig.ServerCertPem, b.Env.InstallConfig.Postgres.ReplicaPrivateKey); err != nil { - return fmt.Errorf("replica PostgreSQL cert/key validation failed: %w", err) - } + if err := b.regeneratePostgresCerts(previousPrimaryIP, previousPrimaryHostname); err != nil { + return err } } @@ -318,6 +286,47 @@ func (b *GCPBootstrapper) UpdateInstallConfig() error { return nil } +// regeneratePostgresCerts regenerates PostgreSQL TLS certificates when the IP/hostname +// changed or no private key was loaded from the vault. +func (b *GCPBootstrapper) regeneratePostgresCerts(previousPrimaryIP, previousPrimaryHostname string) error { + // Only regenerate postgres certificates if the IP or hostname changed, + // or if the private key was not loaded from the vault. + primaryNeedsRegen := b.Env.InstallConfig.Postgres.Primary.PrivateKey == "" || + previousPrimaryIP != b.Env.InstallConfig.Postgres.Primary.IP || + previousPrimaryHostname != b.Env.InstallConfig.Postgres.Primary.Hostname + + if primaryNeedsRegen { + 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 err := installer.ValidateCertKeyPair(b.Env.InstallConfig.Postgres.Primary.SSLConfig.ServerCertPem, b.Env.InstallConfig.Postgres.Primary.PrivateKey); err != nil { + return fmt.Errorf("primary PostgreSQL cert/key validation failed: %w", err) + } + } + // Replica certificates only regenerate if the key is missing from the vault. + if b.Env.InstallConfig.Postgres.Replica != nil && (b.Env.InstallConfig.Postgres.ReplicaPrivateKey == "") { + var err error + 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 err := installer.ValidateCertKeyPair(b.Env.InstallConfig.Postgres.Replica.SSLConfig.ServerCertPem, b.Env.InstallConfig.Postgres.ReplicaPrivateKey); err != nil { + return fmt.Errorf("replica PostgreSQL cert/key validation failed: %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 { diff --git a/internal/bootstrap/gcp/install_config_test.go b/internal/bootstrap/gcp/install_config_test.go index 8c07cdaa..88498eee 100644 --- a/internal/bootstrap/gcp/install_config_test.go +++ b/internal/bootstrap/gcp/install_config_test.go @@ -499,7 +499,7 @@ var _ = Describe("Installconfig & Secrets", func() { origKey := csEnv.InstallConfig.Postgres.Primary.PrivateKey origCert := csEnv.InstallConfig.Postgres.Primary.SSLConfig.ServerCertPem - // GenerateSecrets should NOT be called + icg.EXPECT().GenerateSecrets().Return(nil).Times(0) 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() @@ -538,7 +538,7 @@ var _ = Describe("Installconfig & Secrets", func() { err := bs.UpdateInstallConfig() Expect(err).NotTo(HaveOccurred()) - // IP should be updated to the node's IP + // IP should be updated to the PostgreSQLNode's InternalIP ("10.0.0.1" from fakeNode) Expect(bs.Env.InstallConfig.Postgres.Primary.IP).To(Equal("10.0.0.1")) // Key should be regenerated Expect(bs.Env.InstallConfig.Postgres.Primary.PrivateKey).NotTo(Equal(origKey)) From 34091c26ad1ee80a6c9a25fb83561d28d467e022 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Fri, 10 Apr 2026 15:29:57 +0000 Subject: [PATCH 8/8] chore(docs): Auto-update docs and licenses Signed-off-by: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> --- NOTICE | 4 ++-- internal/tmpl/NOTICE | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/NOTICE b/NOTICE index a770673a..f55d8753 100644 --- a/NOTICE +++ b/NOTICE @@ -53,9 +53,9 @@ License URL: https://github.com/googleapis/google-cloud-go/blob/resourcemanager/ ---------- Module: cloud.google.com/go/serviceusage -Version: v1.10.0 +Version: v1.11.0 License: Apache-2.0 -License URL: https://github.com/googleapis/google-cloud-go/blob/serviceusage/v1.10.0/serviceusage/LICENSE +License URL: https://github.com/googleapis/google-cloud-go/blob/serviceusage/v1.11.0/serviceusage/LICENSE ---------- Module: code.gitea.io/sdk/gitea diff --git a/internal/tmpl/NOTICE b/internal/tmpl/NOTICE index a770673a..f55d8753 100644 --- a/internal/tmpl/NOTICE +++ b/internal/tmpl/NOTICE @@ -53,9 +53,9 @@ License URL: https://github.com/googleapis/google-cloud-go/blob/resourcemanager/ ---------- Module: cloud.google.com/go/serviceusage -Version: v1.10.0 +Version: v1.11.0 License: Apache-2.0 -License URL: https://github.com/googleapis/google-cloud-go/blob/serviceusage/v1.10.0/serviceusage/LICENSE +License URL: https://github.com/googleapis/google-cloud-go/blob/serviceusage/v1.11.0/serviceusage/LICENSE ---------- Module: code.gitea.io/sdk/gitea