diff --git a/cli/cmd/init_install_config.go b/cli/cmd/init_install_config.go index 0be2637f..4c1c7013 100644 --- a/cli/cmd/init_install_config.go +++ b/cli/cmd/init_install_config.go @@ -163,7 +163,7 @@ func AddInitInstallConfigCmd(init *cobra.Command, opts *util.GlobalOptions) { // Postgres c.cmd.Flags().StringVar(&c.Opts.PostgresMode, "postgres-mode", "", "PostgreSQL setup mode (install/external)") - c.cmd.Flags().StringVar(&c.Opts.PostgresServerAddress, "postgres-server", "", "PostgreSQL server hostname for install mode or address for external mode") + c.cmd.Flags().StringVar(&c.Opts.PostgresServerAddress, "postgres-server", "", "PostgreSQL server: primary hostname in install mode, connection address in external mode") c.cmd.Flags().StringVar(&c.Opts.PostgresPrimaryIP, "postgres-primary-ip", "", "Primary PostgreSQL server IP") // K8s @@ -244,7 +244,7 @@ func (c *InitInstallConfigCmd) InitInstallConfig(icg installer.InstallConfigMana return fmt.Errorf("failed to write config file: %w", err) } - if err := icg.WriteVault(c.Opts.VaultFile, c.Opts.WithComments); err != nil { + if err := icg.WriteUnencryptedVault(c.Opts.VaultFile, c.Opts.WithComments); err != nil { return fmt.Errorf("failed to write vault file: %w", err) } @@ -353,12 +353,14 @@ func (c *InitInstallConfigCmd) updateConfigFromOpts(config *files.RootConfig, va config.Postgres.Mode = c.Opts.PostgresMode } - postgresPrimaryHostname := determinePostgresPrimaryHostname(config.Postgres.Mode, c.Opts) + postgresPrimaryHostname, postgresServerAddress := determinePostgresServerConfig( + config.Postgres.Mode, + c.Opts.PostgresServerAddress, + c.Opts.PostgresPrimaryHostname, + config.Postgres.ServerAddress, + ) if c.Opts.PostgresServerAddress != "" { - config.Postgres.ServerAddress = c.Opts.PostgresServerAddress - } - if c.Opts.PostgresServerAddress != "" && config.Postgres.Mode == "install" { - config.Postgres.ServerAddress = "" + config.Postgres.ServerAddress = postgresServerAddress } if postgresPrimaryHostname != "" || c.Opts.PostgresPrimaryIP != "" { @@ -564,14 +566,12 @@ func (c *InitInstallConfigCmd) updateConfigFromOpts(config *files.RootConfig, va return config } -func determinePostgresPrimaryHostname(postgresMode string, opts *InitInstallConfigOpts) string { - if postgresMode != "install" { - return opts.PostgresPrimaryHostname +func determinePostgresServerConfig(postgresMode, postgresServer, primaryHostname, serverAddress string) (string, string) { + if postgresServer == "" { + return primaryHostname, serverAddress } - - if opts.PostgresServerAddress == "" { - return opts.PostgresPrimaryHostname + if postgresMode == "install" { + return postgresServer, "" } - - return opts.PostgresServerAddress + return primaryHostname, postgresServer } diff --git a/cli/cmd/init_install_config_interactive_test.go b/cli/cmd/init_install_config_interactive_test.go index 61b89bd4..1be47680 100644 --- a/cli/cmd/init_install_config_interactive_test.go +++ b/cli/cmd/init_install_config_interactive_test.go @@ -153,7 +153,7 @@ var _ = Describe("Interactive profile usage", func() { mockIcg.EXPECT().ValidateInstallConfig().Return([]string{"configuration validation failed"}) mockIcg.EXPECT().GenerateSecrets().Return(nil) mockIcg.EXPECT().WriteInstallConfig("config.yaml", false).Return(nil) - mockIcg.EXPECT().WriteVault("vault.yaml", false).Return(nil) + mockIcg.EXPECT().WriteUnencryptedVault("vault.yaml", false).Return(nil) c := &InitInstallConfigCmd{ Opts: &InitInstallConfigOpts{ diff --git a/cli/cmd/update_install_config.go b/cli/cmd/update_install_config.go index 347ba0ae..9aeb6855 100644 --- a/cli/cmd/update_install_config.go +++ b/cli/cmd/update_install_config.go @@ -37,6 +37,7 @@ type UpdateInstallConfigOpts struct { PostgresReplicaIP string PostgresReplicaName string PostgresServerAddress string + PostgresServer string CephNodesSubnet string @@ -86,6 +87,8 @@ func AddUpdateInstallConfigCmd(update *cobra.Command, opts *util.GlobalOptions) of the PostgreSQL server certificates that include that IP address.`), Example: util.FormatExamples("update install-config", []csio.Example{ {Cmd: "--postgres-primary-ip 10.10.0.4 --config config.yaml --vault prod.vault.yaml", Desc: "Update PostgreSQL primary IP and regenerate certificates"}, + {Cmd: "--postgres-server postgres-1 --config config.yaml --vault prod.vault.yaml", Desc: "Set the primary PostgreSQL hostname when mode is install"}, + {Cmd: "--postgres-server db.example.com:5432 --config config.yaml --vault prod.vault.yaml", Desc: "Set the PostgreSQL connection address when mode is external"}, {Cmd: "--domain new.example.com --config config.yaml --vault prod.vault.yaml", Desc: "Update Codesphere domain"}, {Cmd: "--k8s-api-server 10.0.0.10 --config config.yaml --vault prod.vault.yaml", Desc: "Update Kubernetes API server host"}, }), @@ -101,10 +104,13 @@ func AddUpdateInstallConfigCmd(update *cobra.Command, opts *util.GlobalOptions) // PostgreSQL update flags c.cmd.Flags().StringVar(&c.Opts.PostgresPrimaryIP, "postgres-primary-ip", "", "Primary PostgreSQL server IP") - c.cmd.Flags().StringVar(&c.Opts.PostgresPrimaryHostname, "postgres-primary-hostname", "", "Primary PostgreSQL server hostname") + c.cmd.Flags().StringVar(&c.Opts.PostgresPrimaryHostname, "postgres-primary-hostname", "", "Primary PostgreSQL server hostname (deprecated: use --postgres-server)") c.cmd.Flags().StringVar(&c.Opts.PostgresReplicaIP, "postgres-replica-ip", "", "Replica PostgreSQL server IP") c.cmd.Flags().StringVar(&c.Opts.PostgresReplicaName, "postgres-replica-name", "", "Replica PostgreSQL server name") - c.cmd.Flags().StringVar(&c.Opts.PostgresServerAddress, "postgres-server-address", "", "PostgreSQL server address (for external mode)") + c.cmd.Flags().StringVar(&c.Opts.PostgresServerAddress, "postgres-server-address", "", "External PostgreSQL connection address (deprecated: use --postgres-server)") + c.cmd.Flags().StringVar(&c.Opts.PostgresServer, "postgres-server", "", "PostgreSQL server: primary hostname in install mode, connection address in external mode") + _ = c.cmd.Flags().MarkDeprecated("postgres-primary-hostname", "use --postgres-server instead") + _ = c.cmd.Flags().MarkDeprecated("postgres-server-address", "use --postgres-server instead") // Ceph update flags c.cmd.Flags().StringVar(&c.Opts.CephNodesSubnet, "ceph-nodes-subnet", "", "Ceph nodes subnet") @@ -199,16 +205,23 @@ func (c *UpdateInstallConfigCmd) applyUpdates(config *files.RootConfig, vault *f } func (c *UpdateInstallConfigCmd) applyPostgresUpdates(config *files.RootConfig, tracker *SecretDependencyTracker) { - if c.Opts.PostgresPrimaryIP != "" || c.Opts.PostgresPrimaryHostname != "" { + primaryHostname, serverAddress := determinePostgresServerConfig( + config.Postgres.Mode, + c.Opts.PostgresServer, + c.Opts.PostgresPrimaryHostname, + c.Opts.PostgresServerAddress, + ) + + if c.Opts.PostgresPrimaryIP != "" || primaryHostname != "" { if config.Postgres.Primary != nil { if c.Opts.PostgresPrimaryIP != "" && config.Postgres.Primary.IP != c.Opts.PostgresPrimaryIP { log.Printf("Updating PostgreSQL primary IP: %s -> %s\n", config.Postgres.Primary.IP, c.Opts.PostgresPrimaryIP) config.Postgres.Primary.IP = c.Opts.PostgresPrimaryIP tracker.MarkPostgresPrimaryCertNeedsRegen() } - if c.Opts.PostgresPrimaryHostname != "" && config.Postgres.Primary.Hostname != c.Opts.PostgresPrimaryHostname { - log.Printf("Updating PostgreSQL primary hostname: %s -> %s\n", config.Postgres.Primary.Hostname, c.Opts.PostgresPrimaryHostname) - config.Postgres.Primary.Hostname = c.Opts.PostgresPrimaryHostname + if primaryHostname != "" && config.Postgres.Primary.Hostname != primaryHostname { + log.Printf("Updating PostgreSQL primary hostname: %s -> %s\n", config.Postgres.Primary.Hostname, primaryHostname) + config.Postgres.Primary.Hostname = primaryHostname tracker.MarkPostgresPrimaryCertNeedsRegen() } } @@ -229,9 +242,9 @@ func (c *UpdateInstallConfigCmd) applyPostgresUpdates(config *files.RootConfig, } } - if c.Opts.PostgresServerAddress != "" && config.Postgres.ServerAddress != c.Opts.PostgresServerAddress { - log.Printf("Updating PostgreSQL server address: %s -> %s\n", config.Postgres.ServerAddress, c.Opts.PostgresServerAddress) - config.Postgres.ServerAddress = c.Opts.PostgresServerAddress + if serverAddress != "" && config.Postgres.ServerAddress != serverAddress { + log.Printf("Updating PostgreSQL server address: %s -> %s\n", config.Postgres.ServerAddress, serverAddress) + config.Postgres.ServerAddress = serverAddress } } @@ -444,8 +457,7 @@ func (c *UpdateInstallConfigCmd) printSuccessMessage(tracker *SecretDependencyTr } } - log.Println("\nIMPORTANT: The vault file has been updated with new secrets.") - log.Println(" Remember to re-encrypt it with SOPS before storing.") + log.Println("\nThe vault file has been updated and re-encrypted with SOPS.") log.Println() } diff --git a/cli/cmd/update_install_config_test.go b/cli/cmd/update_install_config_test.go index 4b7a221b..8d078373 100644 --- a/cli/cmd/update_install_config_test.go +++ b/cli/cmd/update_install_config_test.go @@ -226,7 +226,7 @@ codesphere: Context("when updating PostgreSQL configuration", func() { It("should update primary IP and hostname, and regenerate certificates", func() { opts.PostgresPrimaryIP = "10.10.0.4" - opts.PostgresPrimaryHostname = "new-postgres-primary" + opts.PostgresServer = "new-postgres-primary" icg := installer.NewInstallConfigManager() err := cmd.UpdateInstallConfig(icg) @@ -237,6 +237,13 @@ codesphere: Expect(config.Postgres.Primary.Hostname).To(Equal("new-postgres-primary")) Expect(icg.GetVault().GetSecret(files.SecretPostgresPrimaryServerKeyPem)).NotTo(BeNil()) Expect(config.Postgres.Primary.SSLConfig.ServerCertPem).NotTo(BeEmpty()) + + encrypted, err := vault.IsSOPSEncryptedFile(vaultFile.Name()) + Expect(err).NotTo(HaveOccurred()) + Expect(encrypted).To(BeTrue()) + updatedVault, err := vault.LoadVaultData(vaultFile.Name(), "") + Expect(err).NotTo(HaveOccurred()) + Expect(updatedVault.GetSecret(files.SecretPostgresPrimaryServerKeyPem)).NotTo(BeNil()) }) It("should update replica IP and name, and regenerate certificates", func() { @@ -393,10 +400,7 @@ codesphere: err = cmd.UpdateInstallConfig(icg) Expect(err).NotTo(HaveOccurred()) - updatedVaultContent, err := os.ReadFile(vaultFile.Name()) - Expect(err).NotTo(HaveOccurred()) - updatedVault := &files.InstallVault{} - err = updatedVault.Unmarshal(updatedVaultContent) + updatedVault, err := vault.LoadVaultData(vaultFile.Name(), "") Expect(err).NotTo(HaveOccurred()) // Verify all initial secrets are still present with the same values @@ -427,10 +431,7 @@ codesphere: err = cmd.UpdateInstallConfig(icg) Expect(err).NotTo(HaveOccurred()) - updatedVaultContent, err := os.ReadFile(vaultFile.Name()) - Expect(err).NotTo(HaveOccurred()) - updatedVault := &files.InstallVault{} - err = updatedVault.Unmarshal(updatedVaultContent) + updatedVault, err := vault.LoadVaultData(vaultFile.Name(), "") Expect(err).NotTo(HaveOccurred()) // Verify all initial secrets are still present with the same values diff --git a/docs/oms_init_install-config.md b/docs/oms_init_install-config.md index 71a94595..870b87c7 100644 --- a/docs/oms_init_install-config.md +++ b/docs/oms_init_install-config.md @@ -77,7 +77,7 @@ $ oms init install-config --validate -c config.yaml --vault prod.vault.yaml --openbao-user string Username for OpenBao authentication (default "admin") --postgres-mode string PostgreSQL setup mode (install/external) --postgres-primary-ip string Primary PostgreSQL server IP - --postgres-server string PostgreSQL server hostname for install mode or address for external mode + --postgres-server string PostgreSQL server: primary hostname in install mode, connection address in external mode --profile string Use a predefined configuration profile (dev, production, minimal) --registry-server string Server for container registry --secrets-dir string Secrets base directory (default "/root/secrets") diff --git a/docs/oms_update_install-config.md b/docs/oms_update_install-config.md index 327591e3..54412d20 100644 --- a/docs/oms_update_install-config.md +++ b/docs/oms_update_install-config.md @@ -24,6 +24,12 @@ oms update install-config [flags] # Update PostgreSQL primary IP and regenerate certificates $ oms update install-config --postgres-primary-ip 10.10.0.4 --config config.yaml --vault prod.vault.yaml +# Set the primary PostgreSQL hostname when mode is install +$ oms update install-config --postgres-server postgres-1 --config config.yaml --vault prod.vault.yaml + +# Set the PostgreSQL connection address when mode is external +$ oms update install-config --postgres-server db.example.com:5432 --config config.yaml --vault prod.vault.yaml + # Update Codesphere domain $ oms update install-config --domain new.example.com --config config.yaml --vault prod.vault.yaml @@ -55,11 +61,10 @@ $ oms update install-config --k8s-api-server 10.0.0.10 --config config.yaml --va --k8s-api-server string Kubernetes API server host --k8s-pod-cidr string Kubernetes Pod CIDR --k8s-service-cidr string Kubernetes Service CIDR - --postgres-primary-hostname string Primary PostgreSQL server hostname --postgres-primary-ip string Primary PostgreSQL server IP --postgres-replica-ip string Replica PostgreSQL server IP --postgres-replica-name string Replica PostgreSQL server name - --postgres-server-address string PostgreSQL server address (for external mode) + --postgres-server string PostgreSQL server: primary hostname in install mode, connection address in external mode --public-ip string Codesphere public IP address --vault string Path to existing prod.vault.yaml file (default "prod.vault.yaml") --with-comments Add helpful comments to the generated YAML files diff --git a/internal/bootstrap/gcp/gcp_test.go b/internal/bootstrap/gcp/gcp_test.go index d0d6ff15..49601814 100644 --- a/internal/bootstrap/gcp/gcp_test.go +++ b/internal/bootstrap/gcp/gcp_test.go @@ -235,7 +235,7 @@ var _ = Describe("GCP Bootstrapper", func() { icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, "fake-config-file", "/etc/codesphere/config.yaml").Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, "fake-secret", "/etc/codesphere/secrets/prod.vault.yaml").Return(nil) - icg.EXPECT().WriteVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) // Enable Root Login nodeClient.EXPECT().WaitReady(mock.Anything, mock.Anything).Return(nil).Return(nil) diff --git a/internal/bootstrap/gcp/install_config.go b/internal/bootstrap/gcp/install_config.go index 83ebdf0a..84ac388c 100644 --- a/internal/bootstrap/gcp/install_config.go +++ b/internal/bootstrap/gcp/install_config.go @@ -414,7 +414,7 @@ func (b *GCPBootstrapper) UpdateInstallConfig() error { return fmt.Errorf("failed to write config file: %w", err) } - if err := b.icg.WriteVault(b.Env.SecretsFilePath, true); err != nil { + if err := b.icg.WriteUnencryptedVault(b.Env.SecretsFilePath, true); err != nil { return fmt.Errorf("failed to write vault file: %w", err) } diff --git a/internal/bootstrap/gcp/install_config_test.go b/internal/bootstrap/gcp/install_config_test.go index 6ccd50d0..1c328e50 100644 --- a/internal/bootstrap/gcp/install_config_test.go +++ b/internal/bootstrap/gcp/install_config_test.go @@ -324,7 +324,7 @@ var _ = Describe("Installconfig & Secrets", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() @@ -372,7 +372,7 @@ var _ = Describe("Installconfig & Secrets", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() @@ -388,7 +388,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("uses those internal flags 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) + icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() @@ -405,7 +405,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("uses those preview flags 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) + icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() @@ -422,7 +422,7 @@ var _ = Describe("Installconfig & Secrets", func() { 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) + icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() @@ -439,7 +439,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("writes the email to the 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) + icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() @@ -456,7 +456,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("keeps the value of an existing config", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() @@ -473,7 +473,7 @@ var _ = Describe("Installconfig & Secrets", func() { 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) + icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() @@ -492,7 +492,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("sets GitLab 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) + icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -516,7 +516,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("skips setting GitLab 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) + icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -534,7 +534,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("sets Bitbucket 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) + icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -558,7 +558,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("skips setting Bitbucket 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) + icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -576,7 +576,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("sets Azure DevOps 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) + icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -601,7 +601,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("skips setting Azure DevOps 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) + icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -621,7 +621,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("sets OIDC 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) + icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -648,7 +648,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("defaults OIDC provider name to OIDC", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -662,7 +662,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("skips setting OIDC 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) + icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -680,7 +680,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("sets CentralOtel credentials in install config with Enabled true", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -696,7 +696,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("stores CentralOtel username and password in the vault", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -718,7 +718,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("skips setting CentralOtel credentials", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -736,7 +736,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("skips setting CentralOtel credentials", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -750,7 +750,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("leaves Monitoring.CentralOtel nil", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -768,7 +768,7 @@ var _ = Describe("Installconfig & Secrets", func() { gc.EXPECT().CreatePublicCAExternalAccountKey(mock.Anything).Return("fake-eab-key-id", "fake-eab-mac-key", nil) icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -799,7 +799,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("uses the Let's Encrypt staging directory", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -818,7 +818,7 @@ var _ = Describe("Installconfig & Secrets", func() { 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) + icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() @@ -840,7 +840,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("sets Grafana Alloy Loki config", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() @@ -856,7 +856,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("stores External Loki credentials in the vault", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -875,7 +875,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("sets Grafana Alloy Loki config without a password secret", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() @@ -898,7 +898,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("sets Prometheus remote write config", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -918,7 +918,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("leaves Prometheus remote write nil", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -937,7 +937,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("sets TelemetryExport with RemoteExport true", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -960,7 +960,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("sets TelemetryExport with Traces true and RemoteExport false", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -985,7 +985,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("sets TelemetryExport with both RemoteExport and Traces true", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -1004,7 +1004,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("leaves TelemetryExport nil", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -1033,10 +1033,10 @@ var _ = Describe("Installconfig & Secrets", func() { Expect(err.Error()).To(ContainSubstring("failed to write config file")) }) - It("fails when WriteVault fails", func() { + It("fails when WriteUnencryptedVault 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")) + icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(fmt.Errorf("vault write error")) err := bs.UpdateInstallConfig() Expect(err).To(HaveOccurred()) @@ -1046,7 +1046,7 @@ var _ = Describe("Installconfig & Secrets", func() { 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) + icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(fmt.Errorf("copy error")).Once() @@ -1058,7 +1058,7 @@ var _ = Describe("Installconfig & Secrets", func() { 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) + icg.EXPECT().WriteUnencryptedVault("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() @@ -1095,7 +1095,7 @@ var _ = Describe("Installconfig & Secrets", func() { origCert := csEnv.InstallConfig.Postgres.Primary.SSLConfig.ServerCertPem icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -1127,7 +1127,7 @@ var _ = Describe("Installconfig & Secrets", func() { origKey := vault.GetSecret(files.SecretPostgresPrimaryServerKeyPem).File.Content icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() @@ -1161,7 +1161,7 @@ var _ = Describe("Installconfig & Secrets", func() { It("generates new cert/key pair", func() { icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() err := bs.UpdateInstallConfig() diff --git a/internal/bootstrap/local/local.go b/internal/bootstrap/local/local.go index 8be7f17b..d99d8327 100644 --- a/internal/bootstrap/local/local.go +++ b/internal/bootstrap/local/local.go @@ -665,7 +665,7 @@ func (b *LocalBootstrapper) UpdateInstallConfig() (err error) { return fmt.Errorf("failed to write config file: %w", err) } - if err := b.icg.WriteVault(b.Env.SecretsFilePath, true); err != nil { + if err := b.icg.WriteUnencryptedVault(b.Env.SecretsFilePath, true); err != nil { return fmt.Errorf("failed to write vault file: %w", err) } if err := vault.EncryptFileWithSOPS(b.Env.SecretsFilePath, filepath.Join(b.Env.InstallConfig.Secrets.BaseDir, "prod.vault.yaml"), b.ageRecipient); err != nil { diff --git a/internal/installer/config_manager.go b/internal/installer/config_manager.go index 32223435..171e90d1 100644 --- a/internal/installer/config_manager.go +++ b/internal/installer/config_manager.go @@ -42,12 +42,15 @@ type InstallConfigManager interface { GenerateSecrets() error WriteInstallConfig(configPath string, withComments bool) error WriteVault(vaultPath string, withComments bool) error + WriteUnencryptedVault(vaultPath string, withComments bool) error } type InstallConfig struct { - fileIO util.FileIO - Config *files.RootConfig - Vault *files.InstallVault + fileIO util.FileIO + vaultEncryptor vault.Encryptor + ageKeyResolver vault.AgeKeyResolver + Config *files.RootConfig + Vault *files.InstallVault } // SetFileIO overrides the file I/O implementation (useful for testing). @@ -55,12 +58,38 @@ func (g *InstallConfig) SetFileIO(fio util.FileIO) { g.fileIO = fio } +// SetVaultEncryptor overrides vault encryption (useful for testing). +func (g *InstallConfig) SetVaultEncryptor(encryptor vault.Encryptor) { + g.vaultEncryptor = encryptor +} + +// SetAgeKeyResolver overrides age key resolution (useful for testing). +func (g *InstallConfig) SetAgeKeyResolver(resolver vault.AgeKeyResolver) { + g.ageKeyResolver = resolver +} + +func (g *InstallConfig) encryptVault(src, target, recipient string) error { + if g.vaultEncryptor == nil { + return fmt.Errorf("vault encryptor is not configured") + } + return g.vaultEncryptor.Encrypt(src, target, recipient) +} + +func (g *InstallConfig) resolveAgeKey(explicitKeyFile, fallbackDir string) (recipient, keyPath string, err error) { + if g.ageKeyResolver == nil { + return "", "", fmt.Errorf("age key resolver is not configured") + } + return g.ageKeyResolver.Resolve(explicitKeyFile, fallbackDir) +} + func NewInstallConfigManager() InstallConfigManager { config := files.NewRootConfig() return &InstallConfig{ - fileIO: &util.FilesystemWriter{}, - Config: &config, - Vault: &files.InstallVault{}, + fileIO: &util.FilesystemWriter{}, + vaultEncryptor: vault.SOPSEncryptor{}, + ageKeyResolver: vault.DefaultAgeKeyResolver{}, + Config: &config, + Vault: &files.InstallVault{}, } } @@ -266,9 +295,66 @@ func (g *InstallConfig) WriteInstallConfig(configPath string, withComments bool) return nil } +func (g *InstallConfig) WriteUnencryptedVault(vaultPath string, withComments bool) error { + vaultYAML, err := g.marshalVault(vaultPath, withComments) + if err != nil { + return err + } + + if err := g.fileIO.CreateAndWrite(vaultPath, vaultYAML, "Secrets"); err != nil { + return err + } + + return nil +} + func (g *InstallConfig) WriteVault(vaultPath string, withComments bool) error { + vaultYAML, err := g.marshalVault(vaultPath, withComments) + if err != nil { + return err + } + + recipient, _, err := g.resolveAgeKey("", filepath.Dir(vaultPath)) + if err != nil { + return fmt.Errorf("failed to resolve age key: %w", err) + } + + plainPath, err := g.fileIO.CreateTemp(filepath.Dir(vaultPath), "."+filepath.Base(vaultPath)+".plaintext-*") + if err != nil { + return fmt.Errorf("failed to create temporary plaintext vault: %w", err) + } + defer func() { + _ = g.fileIO.Remove(plainPath) + }() + if err := g.fileIO.WriteFile(plainPath, vaultYAML, 0600); err != nil { + return fmt.Errorf("failed to write temporary plaintext vault: %w", err) + } + + encryptedPath, err := g.fileIO.CreateTemp(filepath.Dir(vaultPath), "."+filepath.Base(vaultPath)+".encrypted-*") + if err != nil { + return fmt.Errorf("failed to create temporary encrypted vault: %w", err) + } + defer func() { + _ = g.fileIO.Remove(encryptedPath) + }() + + if err := g.encryptVault(plainPath, encryptedPath, recipient); err != nil { + return err + } + + if err := g.fileIO.Chmod(encryptedPath, 0600); err != nil { + return fmt.Errorf("failed to set encrypted vault permissions: %w", err) + } + if err := g.fileIO.Rename(encryptedPath, vaultPath); err != nil { + return fmt.Errorf("failed to replace encrypted vault: %w", err) + } + + return nil +} + +func (g *InstallConfig) marshalVault(vaultPath string, withComments bool) ([]byte, error) { if g.Config == nil { - return fmt.Errorf("no configuration provided - config is nil") + return nil, fmt.Errorf("no configuration provided - config is nil") } if g.Vault == nil { g.Vault = &files.InstallVault{} @@ -276,18 +362,14 @@ func (g *InstallConfig) WriteVault(vaultPath string, withComments bool) error { vaultYAML, err := g.Vault.Marshal() if err != nil { - return fmt.Errorf("failed to marshal vault.yaml: %w", err) + return nil, fmt.Errorf("failed to marshal %s: %w", filepath.Base(vaultPath), err) } if withComments { vaultYAML = AddVaultComments(vaultYAML) } - if err := g.fileIO.CreateAndWrite(vaultPath, vaultYAML, "Secrets"); err != nil { - return err - } - - return nil + return vaultYAML, nil } func AddConfigComments(yamlData []byte) []byte { diff --git a/internal/installer/config_manager_test.go b/internal/installer/config_manager_test.go index 1fd403d4..077db592 100644 --- a/internal/installer/config_manager_test.go +++ b/internal/installer/config_manager_test.go @@ -5,7 +5,9 @@ package installer_test import ( "bytes" + "errors" "os" + "path/filepath" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -13,6 +15,7 @@ import ( "github.com/codesphere-cloud/oms/internal/installer" "github.com/codesphere-cloud/oms/internal/installer/files" "github.com/codesphere-cloud/oms/internal/installer/secrets" + "github.com/codesphere-cloud/oms/internal/installer/vault" ) type MockFileIO struct { @@ -46,6 +49,18 @@ func (m *MockFileIO) CreateAndWrite(filePath string, data []byte, fileType strin return nil } +func (m *MockFileIO) CreateTemp(dir, pattern string) (string, error) { + path := filepath.Join(dir, pattern+"mock") + m.files[path] = nil + return path, nil +} + +func (m *MockFileIO) Rename(oldPath, newPath string) error { + m.files[newPath] = m.files[oldPath] + delete(m.files, oldPath) + return nil +} + func (m *MockFileIO) Open(filename string) (*os.File, error) { if m.openError != nil { return nil, m.openError @@ -510,6 +525,34 @@ var _ = Describe("ConfigManager", func() { }) }) + Describe("WriteVault", func() { + It("should preserve the existing vault when encryption fails", func() { + vaultPath := "prod.vault.yaml" + original := []byte("existing encrypted content") + mockIO := NewMockFileIO() + mockIO.files[vaultPath] = original + manager := &installer.InstallConfig{ + Config: &files.RootConfig{}, + Vault: &files.InstallVault{}, + } + ageKeyResolver := vault.NewMockAgeKeyResolver(GinkgoT()) + ageKeyResolver.EXPECT().Resolve("", ".").Return("recipient", "", nil) + encryptor := vault.NewMockEncryptor(GinkgoT()) + encryptor.EXPECT().Encrypt( + ".prod.vault.yaml.plaintext-*mock", + ".prod.vault.yaml.encrypted-*mock", + "recipient", + ).Return(errors.New("encryption failed")) + manager.SetFileIO(mockIO) + manager.SetAgeKeyResolver(ageKeyResolver) + manager.SetVaultEncryptor(encryptor) + + err := manager.WriteVault(vaultPath, false) + Expect(err).To(HaveOccurred()) + Expect(mockIO.GetFileContent(vaultPath)).To(Equal(original)) + }) + }) + Describe("Integration Tests", func() { Context("full configuration lifecycle", func() { It("should apply profile, validate, and prepare for write", func() { @@ -537,7 +580,7 @@ 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() { + It("should not produce duplicate vault entries when WriteUnencryptedVault is called after loading existing vault", func() { mockIO := NewMockFileIO() configManager.SetFileIO(mockIO) @@ -547,8 +590,8 @@ var _ = Describe("ConfigManager", func() { err = configManager.GenerateSecrets() Expect(err).ToNot(HaveOccurred()) - // First write via WriteVault - err = configManager.WriteVault("/tmp/vault.yaml", false) + // First write via WriteUnencryptedVault + err = configManager.WriteUnencryptedVault("/tmp/vault.yaml", false) Expect(err).ToNot(HaveOccurred()) firstVaultBytes := mockIO.GetFileContent("/tmp/vault.yaml") @@ -561,7 +604,7 @@ var _ = Describe("ConfigManager", func() { configManager.Vault = vault // Re-write vault (simulating a second run) - err = configManager.WriteVault("/tmp/vault.yaml", false) + err = configManager.WriteUnencryptedVault("/tmp/vault.yaml", false) Expect(err).ToNot(HaveOccurred()) secondVaultBytes := mockIO.GetFileContent("/tmp/vault.yaml") @@ -600,7 +643,7 @@ var _ = Describe("ConfigManager", func() { // Write config and vault err = configManager.WriteInstallConfig("/tmp/config.yaml", false) Expect(err).ToNot(HaveOccurred()) - err = configManager.WriteVault("/tmp/vault.yaml", false) + err = configManager.WriteUnencryptedVault("/tmp/vault.yaml", false) Expect(err).ToNot(HaveOccurred()) // --- Second run: simulate loading existing files --- @@ -640,7 +683,7 @@ var _ = Describe("ConfigManager", func() { Expect(err).ToNot(HaveOccurred(), "cert/key should match after load from vault") // Write vault again - err = configManager2.WriteVault("/tmp/vault2.yaml", false) + err = configManager2.WriteUnencryptedVault("/tmp/vault2.yaml", false) Expect(err).ToNot(HaveOccurred()) // Verify no duplicates in re-written vault diff --git a/internal/installer/mocks.go b/internal/installer/mocks.go index 3de55b40..34455727 100644 --- a/internal/installer/mocks.go +++ b/internal/installer/mocks.go @@ -753,6 +753,63 @@ func (_c *MockInstallConfigManager_WriteInstallConfig_Call) RunAndReturn(run fun return _c } +// WriteUnencryptedVault provides a mock function for the type MockInstallConfigManager +func (_mock *MockInstallConfigManager) WriteUnencryptedVault(vaultPath string, withComments bool) error { + ret := _mock.Called(vaultPath, withComments) + + if len(ret) == 0 { + panic("no return value specified for WriteUnencryptedVault") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string, bool) error); ok { + r0 = returnFunc(vaultPath, withComments) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockInstallConfigManager_WriteUnencryptedVault_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WriteUnencryptedVault' +type MockInstallConfigManager_WriteUnencryptedVault_Call struct { + *mock.Call +} + +// WriteUnencryptedVault is a helper method to define mock.On call +// - vaultPath string +// - withComments bool +func (_e *MockInstallConfigManager_Expecter) WriteUnencryptedVault(vaultPath any, withComments any) *MockInstallConfigManager_WriteUnencryptedVault_Call { + return &MockInstallConfigManager_WriteUnencryptedVault_Call{Call: _e.mock.On("WriteUnencryptedVault", vaultPath, withComments)} +} + +func (_c *MockInstallConfigManager_WriteUnencryptedVault_Call) Run(run func(vaultPath string, withComments bool)) *MockInstallConfigManager_WriteUnencryptedVault_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + var arg1 bool + if args[1] != nil { + arg1 = args[1].(bool) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockInstallConfigManager_WriteUnencryptedVault_Call) Return(err error) *MockInstallConfigManager_WriteUnencryptedVault_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockInstallConfigManager_WriteUnencryptedVault_Call) RunAndReturn(run func(vaultPath string, withComments bool) error) *MockInstallConfigManager_WriteUnencryptedVault_Call { + _c.Call.Return(run) + return _c +} + // WriteVault provides a mock function for the type MockInstallConfigManager func (_mock *MockInstallConfigManager) WriteVault(vaultPath string, withComments bool) error { ret := _mock.Called(vaultPath, withComments) diff --git a/internal/installer/vault/mocks.go b/internal/installer/vault/mocks.go new file mode 100644 index 00000000..75bd5c16 --- /dev/null +++ b/internal/installer/vault/mocks.go @@ -0,0 +1,198 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package vault + +import ( + mock "github.com/stretchr/testify/mock" +) + +// NewMockEncryptor creates a new instance of MockEncryptor. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockEncryptor(t interface { + mock.TestingT + Cleanup(func()) +}) *MockEncryptor { + mock := &MockEncryptor{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockEncryptor is an autogenerated mock type for the Encryptor type +type MockEncryptor struct { + mock.Mock +} + +type MockEncryptor_Expecter struct { + mock *mock.Mock +} + +func (_m *MockEncryptor) EXPECT() *MockEncryptor_Expecter { + return &MockEncryptor_Expecter{mock: &_m.Mock} +} + +// Encrypt provides a mock function for the type MockEncryptor +func (_mock *MockEncryptor) Encrypt(src string, target string, recipient string) error { + ret := _mock.Called(src, target, recipient) + + if len(ret) == 0 { + panic("no return value specified for Encrypt") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string, string, string) error); ok { + r0 = returnFunc(src, target, recipient) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockEncryptor_Encrypt_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Encrypt' +type MockEncryptor_Encrypt_Call struct { + *mock.Call +} + +// Encrypt is a helper method to define mock.On call +// - src string +// - target string +// - recipient string +func (_e *MockEncryptor_Expecter) Encrypt(src any, target any, recipient any) *MockEncryptor_Encrypt_Call { + return &MockEncryptor_Encrypt_Call{Call: _e.mock.On("Encrypt", src, target, recipient)} +} + +func (_c *MockEncryptor_Encrypt_Call) Run(run func(src string, target string, recipient string)) *MockEncryptor_Encrypt_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 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockEncryptor_Encrypt_Call) Return(err error) *MockEncryptor_Encrypt_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockEncryptor_Encrypt_Call) RunAndReturn(run func(src string, target string, recipient string) error) *MockEncryptor_Encrypt_Call { + _c.Call.Return(run) + return _c +} + +// NewMockAgeKeyResolver creates a new instance of MockAgeKeyResolver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockAgeKeyResolver(t interface { + mock.TestingT + Cleanup(func()) +}) *MockAgeKeyResolver { + mock := &MockAgeKeyResolver{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockAgeKeyResolver is an autogenerated mock type for the AgeKeyResolver type +type MockAgeKeyResolver struct { + mock.Mock +} + +type MockAgeKeyResolver_Expecter struct { + mock *mock.Mock +} + +func (_m *MockAgeKeyResolver) EXPECT() *MockAgeKeyResolver_Expecter { + return &MockAgeKeyResolver_Expecter{mock: &_m.Mock} +} + +// Resolve provides a mock function for the type MockAgeKeyResolver +func (_mock *MockAgeKeyResolver) Resolve(explicitKeyFile string, fallbackDir string) (string, string, error) { + ret := _mock.Called(explicitKeyFile, fallbackDir) + + if len(ret) == 0 { + panic("no return value specified for Resolve") + } + + var r0 string + var r1 string + var r2 error + if returnFunc, ok := ret.Get(0).(func(string, string) (string, string, error)); ok { + return returnFunc(explicitKeyFile, fallbackDir) + } + if returnFunc, ok := ret.Get(0).(func(string, string) string); ok { + r0 = returnFunc(explicitKeyFile, fallbackDir) + } else { + r0 = ret.Get(0).(string) + } + if returnFunc, ok := ret.Get(1).(func(string, string) string); ok { + r1 = returnFunc(explicitKeyFile, fallbackDir) + } else { + r1 = ret.Get(1).(string) + } + if returnFunc, ok := ret.Get(2).(func(string, string) error); ok { + r2 = returnFunc(explicitKeyFile, fallbackDir) + } else { + r2 = ret.Error(2) + } + return r0, r1, r2 +} + +// MockAgeKeyResolver_Resolve_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Resolve' +type MockAgeKeyResolver_Resolve_Call struct { + *mock.Call +} + +// Resolve is a helper method to define mock.On call +// - explicitKeyFile string +// - fallbackDir string +func (_e *MockAgeKeyResolver_Expecter) Resolve(explicitKeyFile any, fallbackDir any) *MockAgeKeyResolver_Resolve_Call { + return &MockAgeKeyResolver_Resolve_Call{Call: _e.mock.On("Resolve", explicitKeyFile, fallbackDir)} +} + +func (_c *MockAgeKeyResolver_Resolve_Call) Run(run func(explicitKeyFile string, fallbackDir string)) *MockAgeKeyResolver_Resolve_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) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockAgeKeyResolver_Resolve_Call) Return(recipient string, keyPath string, err error) *MockAgeKeyResolver_Resolve_Call { + _c.Call.Return(recipient, keyPath, err) + return _c +} + +func (_c *MockAgeKeyResolver_Resolve_Call) RunAndReturn(run func(explicitKeyFile string, fallbackDir string) (string, string, error)) *MockAgeKeyResolver_Resolve_Call { + _c.Call.Return(run) + return _c +} diff --git a/internal/installer/vault/vault_encryption.go b/internal/installer/vault/vault_encryption.go index bc64d864..b82be612 100644 --- a/internal/installer/vault/vault_encryption.go +++ b/internal/installer/vault/vault_encryption.go @@ -19,6 +19,34 @@ import ( var xdgConfigHome = "XDG_CONFIG_HOME" +// Encryptor encrypts a plaintext vault for an age recipient. +// +//mockery:generate: true +type Encryptor interface { + Encrypt(src, target, recipient string) error +} + +// AgeKeyResolver finds the age recipient used to encrypt a vault. +// +//mockery:generate: true +type AgeKeyResolver interface { + Resolve(explicitKeyFile, fallbackDir string) (recipient, keyPath string, err error) +} + +// SOPSEncryptor encrypts vaults using SOPS and age. +type SOPSEncryptor struct{} + +func (SOPSEncryptor) Encrypt(src, target, recipient string) error { + return EncryptFileWithSOPS(src, target, recipient) +} + +// DefaultAgeKeyResolver resolves age keys from the standard SOPS locations. +type DefaultAgeKeyResolver struct{} + +func (DefaultAgeKeyResolver) Resolve(explicitKeyFile, fallbackDir string) (recipient, keyPath string, err error) { + return ResolveAgeKey(explicitKeyFile, fallbackDir) +} + // ResolveAgeKey resolves an existing age key or generates a new one. // // When explicitKeyFile is non-empty it takes priority over everything else: the diff --git a/internal/util/filewriter.go b/internal/util/filewriter.go index 9c9a948b..b9380483 100644 --- a/internal/util/filewriter.go +++ b/internal/util/filewriter.go @@ -22,6 +22,8 @@ type FileIO interface { ReadDir(dirname string) ([]os.DirEntry, error) ReadFile(filename string) ([]byte, error) CreateAndWrite(filePath string, data []byte, fileType string) error + CreateTemp(dir, pattern string) (string, error) + Rename(oldPath, newPath string) error Remove(path string) error Chmod(name string, mode os.FileMode) error } @@ -51,6 +53,23 @@ func (fs *FilesystemWriter) CreateAndWrite(filePath string, data []byte, fileTyp return nil } +func (fs *FilesystemWriter) CreateTemp(dir, pattern string) (string, error) { + file, err := os.CreateTemp(dir, pattern) + if err != nil { + return "", err + } + path := file.Name() + if err := file.Close(); err != nil { + _ = os.Remove(path) + return "", err + } + return path, nil +} + +func (fs *FilesystemWriter) Rename(oldPath, newPath string) error { + return os.Rename(oldPath, newPath) +} + func (fs *FilesystemWriter) Open(filename string) (*os.File, error) { return os.Open(filename) } diff --git a/internal/util/mocks.go b/internal/util/mocks.go index cd37286d..f307cc03 100644 --- a/internal/util/mocks.go +++ b/internal/util/mocks.go @@ -219,6 +219,72 @@ func (_c *MockFileIO_CreateAndWrite_Call) RunAndReturn(run func(filePath string, return _c } +// CreateTemp provides a mock function for the type MockFileIO +func (_mock *MockFileIO) CreateTemp(dir string, pattern string) (string, error) { + ret := _mock.Called(dir, pattern) + + if len(ret) == 0 { + panic("no return value specified for CreateTemp") + } + + var r0 string + var r1 error + if returnFunc, ok := ret.Get(0).(func(string, string) (string, error)); ok { + return returnFunc(dir, pattern) + } + if returnFunc, ok := ret.Get(0).(func(string, string) string); ok { + r0 = returnFunc(dir, pattern) + } else { + r0 = ret.Get(0).(string) + } + if returnFunc, ok := ret.Get(1).(func(string, string) error); ok { + r1 = returnFunc(dir, pattern) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockFileIO_CreateTemp_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateTemp' +type MockFileIO_CreateTemp_Call struct { + *mock.Call +} + +// CreateTemp is a helper method to define mock.On call +// - dir string +// - pattern string +func (_e *MockFileIO_Expecter) CreateTemp(dir any, pattern any) *MockFileIO_CreateTemp_Call { + return &MockFileIO_CreateTemp_Call{Call: _e.mock.On("CreateTemp", dir, pattern)} +} + +func (_c *MockFileIO_CreateTemp_Call) Run(run func(dir string, pattern string)) *MockFileIO_CreateTemp_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) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockFileIO_CreateTemp_Call) Return(s string, err error) *MockFileIO_CreateTemp_Call { + _c.Call.Return(s, err) + return _c +} + +func (_c *MockFileIO_CreateTemp_Call) RunAndReturn(run func(dir string, pattern string) (string, error)) *MockFileIO_CreateTemp_Call { + _c.Call.Return(run) + return _c +} + // Exists provides a mock function for the type MockFileIO func (_mock *MockFileIO) Exists(filename string) bool { ret := _mock.Called(filename) @@ -760,6 +826,63 @@ func (_c *MockFileIO_Remove_Call) RunAndReturn(run func(path string) error) *Moc return _c } +// Rename provides a mock function for the type MockFileIO +func (_mock *MockFileIO) Rename(oldPath string, newPath string) error { + ret := _mock.Called(oldPath, newPath) + + if len(ret) == 0 { + panic("no return value specified for Rename") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string, string) error); ok { + r0 = returnFunc(oldPath, newPath) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockFileIO_Rename_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Rename' +type MockFileIO_Rename_Call struct { + *mock.Call +} + +// Rename is a helper method to define mock.On call +// - oldPath string +// - newPath string +func (_e *MockFileIO_Expecter) Rename(oldPath any, newPath any) *MockFileIO_Rename_Call { + return &MockFileIO_Rename_Call{Call: _e.mock.On("Rename", oldPath, newPath)} +} + +func (_c *MockFileIO_Rename_Call) Run(run func(oldPath string, newPath string)) *MockFileIO_Rename_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) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockFileIO_Rename_Call) Return(err error) *MockFileIO_Rename_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockFileIO_Rename_Call) RunAndReturn(run func(oldPath string, newPath string) error) *MockFileIO_Rename_Call { + _c.Call.Return(run) + return _c +} + // WriteFile provides a mock function for the type MockFileIO func (_mock *MockFileIO) WriteFile(filename string, data []byte, perm os.FileMode) error { ret := _mock.Called(filename, data, perm)