From cd1243d122956f965896a36a97ee3b58552ea725 Mon Sep 17 00:00:00 2001 From: Manuel Dewald Date: Wed, 5 Aug 2026 15:59:42 +0200 Subject: [PATCH 1/2] refac: Remove unused function IsSOPSEncryptedFile --- cli/cmd/k0s/install_k0s_test.go | 13 ----- cli/cmd/update_install_config_test.go | 3 -- .../installer/vault/vault_encryption_test.go | 47 ------------------- .../vault/vault_templating_secret_store.go | 9 ---- 4 files changed, 72 deletions(-) diff --git a/cli/cmd/k0s/install_k0s_test.go b/cli/cmd/k0s/install_k0s_test.go index 0c46fe43..cb993b5e 100644 --- a/cli/cmd/k0s/install_k0s_test.go +++ b/cli/cmd/k0s/install_k0s_test.go @@ -274,10 +274,6 @@ var _ = Describe("InstallK0sCmd", func() { err = c.InstallK0s(mockPM, mockK0s, mockK0sctl) Expect(err).NotTo(HaveOccurred()) - encrypted, err := vault.IsSOPSEncryptedFile(c.Opts.Vault) - Expect(err).NotTo(HaveOccurred()) - Expect(encrypted).To(BeTrue(), "new vault should be SOPS-encrypted") - loaded, err := vault.LoadVaultData(c.Opts.Vault, ageKeyPath) Expect(err).NotTo(HaveOccurred()) secret := loaded.GetSecret(files.SecretKubeConfig) @@ -463,10 +459,6 @@ var _ = Describe("InstallK0sCmd", func() { Expect(err).NotTo(HaveOccurred(), string(encryptOut)) Expect(os.Remove(plainPath)).To(Succeed()) - encrypted, err := vault.IsSOPSEncryptedFile(vaultPath) - Expect(err).NotTo(HaveOccurred()) - Expect(encrypted).To(BeTrue()) - c.Opts.InstallConfig = writeTestConfig(createTestConfig(true)) c.Opts.Package = "test-package.tar.gz" c.Opts.Version = "v1.30.0+k0s.0" @@ -479,11 +471,6 @@ var _ = Describe("InstallK0sCmd", func() { err = c.InstallK0s(mockPM, mockK0s, mockK0sctl) Expect(err).NotTo(HaveOccurred()) - // Verify the vault was re-encrypted after saving kubeconfig. - encrypted, err = vault.IsSOPSEncryptedFile(vaultPath) - Expect(err).NotTo(HaveOccurred()) - Expect(encrypted).To(BeTrue(), "vault should be re-encrypted after saving kubeconfig") - // Verify the temporary file was cleaned up. tmpPath := vaultPath + ".tmp" Expect(tmpPath).NotTo(BeAnExistingFile()) diff --git a/cli/cmd/update_install_config_test.go b/cli/cmd/update_install_config_test.go index 8d078373..9fb6bb31 100644 --- a/cli/cmd/update_install_config_test.go +++ b/cli/cmd/update_install_config_test.go @@ -238,9 +238,6 @@ codesphere: 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()) diff --git a/internal/installer/vault/vault_encryption_test.go b/internal/installer/vault/vault_encryption_test.go index ba392b56..022e99dd 100644 --- a/internal/installer/vault/vault_encryption_test.go +++ b/internal/installer/vault/vault_encryption_test.go @@ -161,53 +161,6 @@ var _ = Describe("VaultEncryption", func() { }) }) - Describe("IsSOPSEncryptedFile", func() { - var tmpDir string - - BeforeEach(func() { - var err error - tmpDir, err = os.MkdirTemp("", "sops-detect-test-*") - Expect(err).ToNot(HaveOccurred()) - }) - - AfterEach(func() { - Expect(os.RemoveAll(tmpDir)).To(Succeed()) - }) - - It("returns false for a file without sops metadata", func() { - path := filepath.Join(tmpDir, "plain.yaml") - Expect(os.WriteFile(path, []byte("key: value\n"), 0644)).To(Succeed()) - - encrypted, err := vault.IsSOPSEncryptedFile(path) - Expect(err).ToNot(HaveOccurred()) - Expect(encrypted).To(BeFalse()) - }) - - It("returns true for a file with sops top-level key", func() { - path := filepath.Join(tmpDir, "sops.yaml") - Expect(os.WriteFile(path, []byte("sops:\n age: age1abc\n"), 0644)).To(Succeed()) - - encrypted, err := vault.IsSOPSEncryptedFile(path) - Expect(err).ToNot(HaveOccurred()) - Expect(encrypted).To(BeTrue()) - }) - - It("returns false for an empty file", func() { - path := filepath.Join(tmpDir, "empty.yaml") - Expect(os.WriteFile(path, []byte{}, 0644)).To(Succeed()) - - encrypted, err := vault.IsSOPSEncryptedFile(path) - Expect(err).ToNot(HaveOccurred()) - Expect(encrypted).To(BeFalse()) - }) - - It("returns an error for a non-existent file", func() { - path := filepath.Join(tmpDir, "missing.yaml") - _, err := vault.IsSOPSEncryptedFile(path) - Expect(err).To(HaveOccurred()) - }) - }) - Describe("LoadVaultData", func() { var tmpDir string diff --git a/internal/installer/vault/vault_templating_secret_store.go b/internal/installer/vault/vault_templating_secret_store.go index 9a086505..a886f6b8 100644 --- a/internal/installer/vault/vault_templating_secret_store.go +++ b/internal/installer/vault/vault_templating_secret_store.go @@ -168,15 +168,6 @@ func LoadUnencryptedVaultData(vaultPath string) (*files.InstallVault, error) { return vault, nil } -// IsSOPSEncryptedFile checks whether the file at path is a SOPS-encrypted YAML document. -func IsSOPSEncryptedFile(path string) (bool, error) { - data, err := os.ReadFile(path) - if err != nil { - return false, err - } - return isSOPSEncryptedYAML(data) -} - // isSOPSEncryptedYAML checks whether the YAML document contains SOPS metadata. // SOPS-encrypted YAML files have a top-level "sops" mapping that stores // encryption metadata such as age recipients, encrypted data keys, and MACs. From cde3db2c0ba8dd8de6d320d5aebee9d7f2178118 Mon Sep 17 00:00:00 2001 From: Manuel Dewald Date: Wed, 5 Aug 2026 17:06:45 +0200 Subject: [PATCH 2/2] refac(vault): Refactor vault handling --- cli/cmd/beta_vault_secret.go | 24 ++- cli/cmd/bootstrap_gcp.go | 6 +- cli/cmd/bootstrap_gcp_postconfig.go | 5 +- cli/cmd/bootstrap_local.go | 6 +- cli/cmd/codesphere/codesphere_suite_test.go | 16 ++ cli/cmd/codesphere/install_codesphere.go | 37 +++- .../install_codesphere_config_test.go | 13 ++ .../install_codesphere_dependencies.go | 5 +- .../codesphere/install_codesphere_infra.go | 3 + .../codesphere/install_codesphere_platform.go | 5 +- cli/cmd/codesphere/install_codesphere_test.go | 3 + cli/cmd/init_install_config.go | 22 +- .../init_install_config_interactive_test.go | 12 +- cli/cmd/init_install_config_test.go | 11 +- cli/cmd/install_config_test_helpers_test.go | 23 ++ .../k0s/install_config_test_helpers_test.go | 17 ++ cli/cmd/k0s/install_k0s.go | 49 ++--- cli/cmd/k0s/install_k0s_integration_test.go | 10 +- cli/cmd/k0s/install_k0s_test.go | 16 +- cli/cmd/template_config.go | 20 +- cli/cmd/update_install_config.go | 9 +- cli/cmd/update_install_config_test.go | 35 ++-- docs/oms_beta.md | 2 +- docs/oms_beta_vault-secret.md | 11 +- docs/oms_init_install-config.md | 2 + docs/oms_install_codesphere.md | 4 +- docs/oms_install_codesphere_dependencies.md | 4 +- docs/oms_install_codesphere_infra.md | 4 +- docs/oms_install_codesphere_platform.md | 4 +- docs/oms_install_k0s.md | 1 + docs/oms_template_config.md | 9 +- docs/oms_update_install-config.md | 2 + internal/bootstrap/gcp/gcp_test.go | 15 +- internal/bootstrap/gcp/install_config.go | 15 +- internal/bootstrap/gcp/install_config_test.go | 95 ++++----- .../gcp/install_config_test_helpers_test.go | 16 ++ internal/bootstrap/local/local.go | 32 ++- .../installer/argocd/install_and_apps_test.go | 2 +- internal/installer/cluster_admin.go | 22 +- internal/installer/cluster_admin_test.go | 26 +-- .../config_generator_collector_test.go | 2 +- internal/installer/config_manager.go | 161 +++++--------- .../installer/config_manager_ansible_test.go | 2 +- .../installer/config_manager_profile_test.go | 14 +- .../installer/config_manager_secrets_test.go | 12 +- internal/installer/config_manager_test.go | 77 +++---- .../config_manager_test_helpers_test.go | 16 ++ internal/installer/config_template_test.go | 4 +- internal/installer/mocks.go | 57 ----- internal/installer/vault/mocks.go | 198 ------------------ internal/installer/vault/plain_vault.go | 72 +++++++ internal/installer/vault/sops_vault.go | 183 ++++++++++++++++ internal/installer/vault/vault.go | 151 +++++++++++++ internal/installer/vault/vault_encryption.go | 69 +++--- .../installer/vault/vault_encryption_test.go | 37 ++-- .../installer/vault/vault_secret_creator.go | 20 +- internal/installer/vault/vault_store_test.go | 97 +++++++++ internal/installer/vault/vault_suite_test.go | 16 ++ .../vault/vault_templating_secret_store.go | 87 ++------ 59 files changed, 1093 insertions(+), 795 deletions(-) create mode 100644 cli/cmd/codesphere/codesphere_suite_test.go create mode 100644 cli/cmd/install_config_test_helpers_test.go create mode 100644 cli/cmd/k0s/install_config_test_helpers_test.go create mode 100644 internal/bootstrap/gcp/install_config_test_helpers_test.go create mode 100644 internal/installer/config_manager_test_helpers_test.go delete mode 100644 internal/installer/vault/mocks.go create mode 100644 internal/installer/vault/plain_vault.go create mode 100644 internal/installer/vault/sops_vault.go create mode 100644 internal/installer/vault/vault.go create mode 100644 internal/installer/vault/vault_store_test.go create mode 100644 internal/installer/vault/vault_suite_test.go diff --git a/cli/cmd/beta_vault_secret.go b/cli/cmd/beta_vault_secret.go index 2a5d8dd7..0751f599 100644 --- a/cli/cmd/beta_vault_secret.go +++ b/cli/cmd/beta_vault_secret.go @@ -27,6 +27,7 @@ type BetaVaultSecretOpts struct { AgeKeyPath string Namespace string SecretName string + VaultType string } func (c *BetaVaultSecretCmd) RunE(_ *cobra.Command, _ []string) error { @@ -47,16 +48,26 @@ func (c *BetaVaultSecretCmd) RunE(_ *cobra.Command, _ []string) error { creator := vault.NewVaultSecretCreator(kubeClient) - return creator.CreateSecretFromFile(c.cmd.Context(), c.Opts.VaultFile, c.Opts.AgeKeyPath, c.Opts.Namespace, c.Opts.SecretName) + store, err := vault.NewFromString(c.Opts.VaultType, vault.Options{Path: c.Opts.VaultFile, AgeKey: c.Opts.AgeKeyPath}) + if err != nil { + return fmt.Errorf("failed to load vault: %w", err) + } + + err = creator.CreateSecretFromStore(c.cmd.Context(), store, c.Opts.Namespace, c.Opts.SecretName) + if err != nil { + return fmt.Errorf("failed to create secret: %w", err) + } + + return nil } func AddBetaVaultSecretCmd(parentCmd *cobra.Command, opts *util.GlobalOptions) { cmd := BetaVaultSecretCmd{ cmd: &cobra.Command{ Use: "vault-secret", - Short: "Create a Kubernetes secret from a SOPS-encrypted vault file", - Long: packageio.Long(`Create a Kubernetes secret from a SOPS-encrypted prod.vault.yaml file. - Reads the encrypted vault file, decrypts it using the age key, and creates a Kubernetes secret + Short: "Create a Kubernetes secret from a vault file", + Long: packageio.Long(`Create a Kubernetes secret from a prod.vault.yaml file. + Loads the selected vault type and creates a Kubernetes secret with all the vault entries as key-value pairs in the target cluster.`), Example: util.FormatExamples("vault-secret", []packageio.Example{ {Cmd: "--vault-file prod.vault.yaml --namespace default --secret-name vault-secrets", Desc: "Create secret using default age key location"}, @@ -66,8 +77,9 @@ func AddBetaVaultSecretCmd(parentCmd *cobra.Command, opts *util.GlobalOptions) { Opts: BetaVaultSecretOpts{GlobalOptions: opts}, } - cmd.cmd.Flags().StringVar(&cmd.Opts.VaultFile, "vault-file", "", "Path to the SOPS-encrypted vault file (required)") - cmd.cmd.Flags().StringVar(&cmd.Opts.AgeKeyPath, "age-key", "", "Path to the age key file (optional, will use defaults if not provided)") + cmd.cmd.Flags().StringVar(&cmd.Opts.VaultFile, "vault-file", "", "Path to the vault file (required)") + cmd.cmd.Flags().StringVar(&cmd.Opts.AgeKeyPath, "age-key", "", "Path to the age key file (required for sops unless an age key environment variable is set)") + cmd.cmd.Flags().StringVar(&cmd.Opts.VaultType, "vault-type", "sops", "Vault storage type (sops or plain)") cmd.cmd.Flags().StringVar(&cmd.Opts.Namespace, "namespace", "codesphere", "Kubernetes namespace where the secret will be created") cmd.cmd.Flags().StringVar(&cmd.Opts.SecretName, "secret-name", "cs-vault", "Name of the Kubernetes secret to create") diff --git a/cli/cmd/bootstrap_gcp.go b/cli/cmd/bootstrap_gcp.go index 8f87eae5..ab657f85 100644 --- a/cli/cmd/bootstrap_gcp.go +++ b/cli/cmd/bootstrap_gcp.go @@ -149,7 +149,11 @@ func AddBootstrapGcpCmd(parent *cobra.Command, opts *util.GlobalOptions) { func (c *BootstrapGcpCmd) BootstrapGcp() error { ctx := c.cmd.Context() stlog := bootstrap.NewStepLogger(false) - icg := installer.NewInstallConfigManager() + + icg, err := installer.NewInstallConfigManager("plain", "") + if err != nil { + return fmt.Errorf("failed to initialize conig manager: %w", err) + } gcpClient := gcp.NewGCPClient(ctx, stlog, os.Getenv("GOOGLE_APPLICATION_CREDENTIALS")) fw := intutil.NewFilesystemWriter() portalClient := portal.NewPortalClient() diff --git a/cli/cmd/bootstrap_gcp_postconfig.go b/cli/cmd/bootstrap_gcp_postconfig.go index d0eb064b..7f894129 100644 --- a/cli/cmd/bootstrap_gcp_postconfig.go +++ b/cli/cmd/bootstrap_gcp_postconfig.go @@ -31,7 +31,10 @@ type BootstrapGcpPostconfigOpts struct { func (c *BootstrapGcpPostconfigCmd) RunE(_ *cobra.Command, args []string) error { log.Printf("running post-configuration steps...") - icg := installer.NewInstallConfigManager() + icg, err := installer.NewInstallConfigManager("plain", "") + if err != nil { + return fmt.Errorf("failed to initialize config manager: %w", err) + } fw := intutil.NewFilesystemWriter() infraFilePath := gcp.GetInfraFilePath() diff --git a/cli/cmd/bootstrap_local.go b/cli/cmd/bootstrap_local.go index 19c4fb9f..e3b6a774 100644 --- a/cli/cmd/bootstrap_local.go +++ b/cli/cmd/bootstrap_local.go @@ -141,7 +141,11 @@ func (c *BootstrapLocalCmd) BootstrapLocal() error { } stlog := bootstrap.NewStepLogger(false) - icg := installer.NewInstallConfigManager() + + icg, err := installer.NewInstallConfigManager("plain", "") + if err != nil { + return fmt.Errorf("failed to initialize config manager: %w", err) + } fw := intutil.NewFilesystemWriter() kubeClient, restConfig, err := c.GetKubeClient(ctx) if err != nil { diff --git a/cli/cmd/codesphere/codesphere_suite_test.go b/cli/cmd/codesphere/codesphere_suite_test.go new file mode 100644 index 00000000..b34e884f --- /dev/null +++ b/cli/cmd/codesphere/codesphere_suite_test.go @@ -0,0 +1,16 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package codesphere_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestCodesphere(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Codesphere Command Suite") +} diff --git a/cli/cmd/codesphere/install_codesphere.go b/cli/cmd/codesphere/install_codesphere.go index 141bf27b..96c8c23e 100644 --- a/cli/cmd/codesphere/install_codesphere.go +++ b/cli/cmd/codesphere/install_codesphere.go @@ -41,6 +41,7 @@ type InstallCodesphereOpts struct { ConfigPath string Vault string PrivKey string + VaultType string SkipSteps []string CodesphereOnly bool DirectConnection bool @@ -55,6 +56,9 @@ type InstallCodesphereOpts struct { } func (c *InstallCodesphereCmd) RunE(cmd *cobra.Command, _ []string) error { + if err := validateInstallCodesphereVault(c.Opts); err != nil { + return err + } ctx := cmd.Context() effectiveOpts, cfg, cleanup, err := prepareInstallConfig(c.Opts, installer.NewConfig()) if err != nil { @@ -116,14 +120,14 @@ func AddInstallCmd(install *cobra.Command, opts *util.GlobalOptions) { }, }), }, - Opts: &InstallCodesphereOpts{GlobalOptions: opts}, + Opts: &InstallCodesphereOpts{GlobalOptions: opts, VaultType: string(vault.TypeSOPS)}, Env: env.NewEnv(), } codesphere.cmd.PersistentFlags().StringVarP(&codesphere.Opts.Package, "package", "p", "", "Package file (e.g. codesphere-v1.2.3-installer-lite.tar.gz) to load binaries, installer etc. from") codesphere.cmd.PersistentFlags().BoolVarP(&codesphere.Opts.Force, "force", "f", false, "Enforce package extraction") codesphere.cmd.PersistentFlags().StringArrayVarP(&codesphere.Opts.Configs, "config", "c", nil, "Path to a Codesphere Private Cloud configuration file (yaml). Can be specified multiple times and merged in order") - codesphere.cmd.PersistentFlags().StringVar(&codesphere.Opts.Vault, "vault", "", "Path to the SOPS-encrypted prod.vault.yaml file used for config templating") - codesphere.cmd.PersistentFlags().StringVarP(&codesphere.Opts.PrivKey, "priv-key", "k", "", "Path to the private key to encrypt/decrypt secrets") + codesphere.cmd.PersistentFlags().StringVar(&codesphere.Opts.Vault, "vault", "", "Path to the prod.vault.yaml file used for config templating") + codesphere.cmd.PersistentFlags().StringVarP(&codesphere.Opts.PrivKey, "priv-key", "k", "", "Path to the age private key (required for sops unless an age key environment variable is set)") codesphere.cmd.PersistentFlags().StringSliceVarP(&codesphere.Opts.SkipSteps, "skip-steps", "s", []string{}, "Steps to be skipped. E.g. copy-dependencies, extract-dependencies, load-container-images, ceph, postgres, kubernetes, docker, argocd") codesphere.cmd.PersistentFlags().BoolVar(&codesphere.Opts.DirectConnection, "direct-connection", false, "Use direct connection for installation, requires having access to the cluster nodes from your machine") codesphere.cmd.PersistentFlags().BoolVar(&codesphere.Opts.AutoApprove, "auto-approve", true, "Auto approve confirmation prompts with default values") @@ -137,7 +141,6 @@ func AddInstallCmd(install *cobra.Command, opts *util.GlobalOptions) { util.MarkPersistentFlagRequired(codesphere.cmd, "package") util.MarkPersistentFlagRequired(codesphere.cmd, "config") - util.MarkPersistentFlagRequired(codesphere.cmd, "priv-key") util.AddCmd(install, codesphere.cmd) @@ -148,6 +151,21 @@ func AddInstallCmd(install *cobra.Command, opts *util.GlobalOptions) { AddInstallCodespherePlatformCmd(codesphere.cmd, codesphere.Opts) } +// validateInstallCodesphereVault enforces the current TypeScript installer +// contract without changing the selected type on the command options. +func validateInstallCodesphereVault(opts *InstallCodesphereOpts) error { + if opts.VaultType != string(vault.TypeSOPS) { + return fmt.Errorf("install codesphere requires vault type %q", vault.TypeSOPS) + } + + err := vault.ValidateConfiguration(vault.TypeSOPS, opts.PrivKey) + if err != nil { + return fmt.Errorf("failed to validate install config: %w", err) + } + + return nil +} + func sharedInstallCodesphereSteps() []string { return []string{"copy-dependencies", "extract-dependencies"} } @@ -168,7 +186,16 @@ func prepareInstallConfig(opts *InstallCodesphereOpts, cm installer.ConfigManage return nil, files.RootConfig{}, func() {}, fmt.Errorf("no config.yaml input provided: at least one config file is required") } - store := vault.NewLazyVaultTemplatingSecretStore(opts.Vault, opts.PrivKey) + var store *vault.VaultTemplatingSecretStore + + if opts.Vault != "" { + backend, err := vault.NewFromString(opts.VaultType, vault.Options{Path: opts.Vault, AgeKey: opts.PrivKey}) + if err != nil { + return nil, files.RootConfig{}, func() {}, fmt.Errorf("failed to load vault: %w", err) + } + + store = vault.NewLazyVaultTemplatingSecretStoreWithVault(backend) + } cleanupFns := []func(){} cleanup := func() { for i := len(cleanupFns) - 1; i >= 0; i-- { diff --git a/cli/cmd/codesphere/install_codesphere_config_test.go b/cli/cmd/codesphere/install_codesphere_config_test.go index f26872d3..6a75963e 100644 --- a/cli/cmd/codesphere/install_codesphere_config_test.go +++ b/cli/cmd/codesphere/install_codesphere_config_test.go @@ -232,3 +232,16 @@ func installCodesphereSopsAndAgeAvailable() bool { } return true } + +var _ = Describe("install codesphere vault type", func() { + It("accepts sops", func() { + opts := &InstallCodesphereOpts{VaultType: string(vault.TypeSOPS), PrivKey: "age-key.txt"} + Expect(validateInstallCodesphereVault(opts)).To(Succeed()) + }) + + It("rejects plain vaults at the command boundary", func() { + opts := &InstallCodesphereOpts{VaultType: string(vault.TypePlain), PrivKey: "age-key.txt"} + err := validateInstallCodesphereVault(opts) + Expect(err).To(MatchError(`install codesphere requires vault type "sops"`)) + }) +}) diff --git a/cli/cmd/codesphere/install_codesphere_dependencies.go b/cli/cmd/codesphere/install_codesphere_dependencies.go index 799b532d..66b9537a 100644 --- a/cli/cmd/codesphere/install_codesphere_dependencies.go +++ b/cli/cmd/codesphere/install_codesphere_dependencies.go @@ -32,6 +32,9 @@ type InstallCodesphereDepenciesCmd struct { } func (c *InstallCodesphereDepenciesCmd) RunE(_ *cobra.Command, _ []string) error { + if err := validateInstallCodesphereVault(c.Opts); err != nil { + return err + } effectiveOpts, cfg, cleanup, err := prepareInstallConfig(c.Opts, installer.NewConfig()) if err != nil { return err @@ -81,7 +84,7 @@ func installCodesphereDepencies(opts *InstallCodesphereOpts, cfg files.RootConfi func installArgoCDAndApps(opts *InstallCodesphereOpts, cfg files.RootConfig, pm installer.PackageManager, stlog *bootstrap.StepLogger) error { var install *argocdinstaller.AppInstaller if err := stlog.Substep("Load vault data", func() error { - installVault, restConfig, err := installer.VaultAndRESTConfig(opts.Vault, opts.PrivKey, cfg) + installVault, restConfig, err := installer.VaultAndRESTConfig(opts.Vault, opts.PrivKey, opts.VaultType, cfg) if err != nil { return err } diff --git a/cli/cmd/codesphere/install_codesphere_infra.go b/cli/cmd/codesphere/install_codesphere_infra.go index 70274e9b..a18b0bbd 100644 --- a/cli/cmd/codesphere/install_codesphere_infra.go +++ b/cli/cmd/codesphere/install_codesphere_infra.go @@ -24,6 +24,9 @@ type InstallCodesphereInfraCmd struct { } func (c *InstallCodesphereInfraCmd) RunE(_ *cobra.Command, _ []string) error { + if err := validateInstallCodesphereVault(c.Opts); err != nil { + return err + } effectiveOpts, _, cleanup, err := prepareInstallConfig(c.Opts, installer.NewConfig()) if err != nil { return err diff --git a/cli/cmd/codesphere/install_codesphere_platform.go b/cli/cmd/codesphere/install_codesphere_platform.go index 7b9a7c3e..39b400ae 100644 --- a/cli/cmd/codesphere/install_codesphere_platform.go +++ b/cli/cmd/codesphere/install_codesphere_platform.go @@ -25,6 +25,9 @@ type InstallCodespherePlatformCmd struct { } func (c *InstallCodespherePlatformCmd) RunE(cmd *cobra.Command, _ []string) error { + if err := validateInstallCodesphereVault(c.Opts); err != nil { + return err + } effectiveOpts, cfg, cleanup, err := prepareInstallConfig(c.Opts, installer.NewConfig()) if err != nil { return err @@ -35,7 +38,7 @@ func (c *InstallCodespherePlatformCmd) RunE(cmd *cobra.Command, _ []string) erro } func installCodespherePlatform(ctx context.Context, opts *InstallCodesphereOpts, cfg files.RootConfig, env env.Env) error { - if err := installer.EnsureClusterAdminSecret(ctx, opts.Vault, opts.PrivKey, cfg); err != nil { + if err := installer.EnsureClusterAdminSecret(ctx, opts.Vault, opts.PrivKey, opts.VaultType, cfg); err != nil { return fmt.Errorf("failed to set cluster admin email: %w", err) } diff --git a/cli/cmd/codesphere/install_codesphere_test.go b/cli/cmd/codesphere/install_codesphere_test.go index 2e362320..d2970d83 100644 --- a/cli/cmd/codesphere/install_codesphere_test.go +++ b/cli/cmd/codesphere/install_codesphere_test.go @@ -32,6 +32,8 @@ var _ = Describe("InstallCodesphereCmd", func() { GlobalOptions: globalOpts, Package: "codesphere-v1.66.0-installer-lite.tar.gz", Force: false, + VaultType: "sops", + PrivKey: "age-key.txt", } c = codesphere.InstallCodesphereCmd{ Opts: opts, @@ -124,6 +126,7 @@ var _ = Describe("AddInstallCodesphereCmd", func() { vaultFlag := codesphereCmd.PersistentFlags().Lookup("vault") Expect(vaultFlag).NotTo(BeNil()) Expect(vaultFlag.DefValue).To(Equal("")) + Expect(codesphereCmd.PersistentFlags().Lookup("vault-type")).To(BeNil()) skipStepFlag := codesphereCmd.PersistentFlags().Lookup("skip-steps") Expect(skipStepFlag).NotTo(BeNil()) diff --git a/cli/cmd/init_install_config.go b/cli/cmd/init_install_config.go index 2277528e..25e87c4c 100644 --- a/cli/cmd/init_install_config.go +++ b/cli/cmd/init_install_config.go @@ -27,6 +27,8 @@ type InitInstallConfigOpts struct { ConfigFile string VaultFile string + VaultType string + AgeKey string Profile string AnsibleInventoryFile string @@ -100,7 +102,10 @@ type InitInstallConfigOpts struct { } func (c *InitInstallConfigCmd) RunE(_ *cobra.Command, args []string) error { - icg := installer.NewInstallConfigManager() + icg, err := installer.NewInstallConfigManager(c.Opts.VaultType, c.Opts.AgeKey) + if err != nil { + return fmt.Errorf("failed to initialize config manager: %w", err) + } return c.InitInstallConfig(icg) } @@ -142,6 +147,8 @@ func AddInitInstallConfigCmd(init *cobra.Command, opts *util.GlobalOptions) { c.cmd.Flags().StringVarP(&c.Opts.ConfigFile, "config", "c", "config.yaml", "Output file path for config.yaml") c.cmd.Flags().StringVar(&c.Opts.VaultFile, "vault", "prod.vault.yaml", "Output file path for prod.vault.yaml") + c.cmd.Flags().StringVar(&c.Opts.VaultType, "vault-type", "sops", "Vault storage type (sops or plain)") + c.cmd.Flags().StringVar(&c.Opts.AgeKey, "age-key", "", "Path to the age private key (required for sops unless SOPS_AGE_KEY or SOPS_AGE_KEY_FILE is set)") c.cmd.Flags().StringVar(&c.Opts.Profile, "profile", "", "Use a predefined configuration profile (dev, production, minimal)") c.cmd.Flags().StringVar(&c.Opts.AnsibleInventoryFile, "ansible-inventory", "", "Path to Ansible inventory file to import host information from") @@ -244,7 +251,7 @@ func (c *InitInstallConfigCmd) InitInstallConfig(icg installer.InstallConfigMana return fmt.Errorf("failed to write config file: %w", err) } - if err := icg.WriteUnencryptedVault(c.Opts.VaultFile, c.Opts.WithComments); err != nil { + if err := icg.WriteVault(c.Opts.VaultFile, c.Opts.WithComments); err != nil { return fmt.Errorf("failed to write vault file: %w", err) } @@ -280,16 +287,7 @@ func (c *InitInstallConfigCmd) printSuccessMessage(warningCount int) { log.Println(strings.Repeat("=", 70)) log.Println("\nIMPORTANT: Keys and certificates have been generated and embedded in the vault file.") - log.Println(" Keep the vault file secure and encrypt it with SOPS before storing.") - - log.Println("\nNext steps:") - log.Println("1. Review the generated config.yaml and prod.vault.yaml") - log.Println("2. Install SOPS and Age: brew install sops age") - log.Println("3. Generate an Age keypair: age-keygen -o age_key.txt") - log.Println("4. Encrypt the vault file:") - log.Printf(" age-keygen -y age_key.txt # Get public key\n") - log.Printf(" sops --encrypt --age --in-place %s\n", c.Opts.VaultFile) - log.Println("5. Run the Codesphere installer with these configuration files") + log.Println(" Keep the vault file and its decryption key secure.") log.Println() } diff --git a/cli/cmd/init_install_config_interactive_test.go b/cli/cmd/init_install_config_interactive_test.go index 1be47680..3119eb40 100644 --- a/cli/cmd/init_install_config_interactive_test.go +++ b/cli/cmd/init_install_config_interactive_test.go @@ -18,7 +18,7 @@ import ( var _ = Describe("Interactive profile usage", func() { Context("when using profile with interactive mode", func() { It("should use profile values as defaults", func() { - icg := installer.NewInstallConfigManager() + icg := newPlainInstallConfigManager() // Apply dev profile first (like the command does) err := icg.ApplyProfile("dev") @@ -65,7 +65,7 @@ var _ = Describe("Interactive profile usage", func() { }) It("should allow non-interactive collection to use profile defaults", func() { - icg := installer.NewInstallConfigManager() + icg := newPlainInstallConfigManager() // Apply dev profile err := icg.ApplyProfile("dev") @@ -108,7 +108,7 @@ var _ = Describe("Interactive profile usage", func() { FileWriter: intutil.NewFilesystemWriter(), } - icg := installer.NewInstallConfigManager() + icg := newPlainInstallConfigManager() err = c.InitInstallConfig(icg) Expect(err).NotTo(HaveOccurred()) @@ -131,7 +131,7 @@ var _ = Describe("Interactive profile usage", func() { Context("when using production profile", func() { It("should set production-specific defaults", func() { - icg := installer.NewInstallConfigManager() + icg := newPlainInstallConfigManager() err := icg.ApplyProfile("production") Expect(err).NotTo(HaveOccurred()) @@ -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().WriteUnencryptedVault("vault.yaml", false).Return(nil) + mockIcg.EXPECT().WriteVault("vault.yaml", false).Return(nil) c := &InitInstallConfigCmd{ Opts: &InitInstallConfigOpts{ @@ -195,7 +195,7 @@ var _ = Describe("Interactive profile usage", func() { FileWriter: intutil.NewFilesystemWriter(), } - icg := installer.NewInstallConfigManager() + icg := newPlainInstallConfigManager() err = c.InitInstallConfig(icg) Expect(err).To(HaveOccurred()) diff --git a/cli/cmd/init_install_config_test.go b/cli/cmd/init_install_config_test.go index d098b641..936394f7 100644 --- a/cli/cmd/init_install_config_test.go +++ b/cli/cmd/init_install_config_test.go @@ -13,7 +13,6 @@ import ( . "github.com/onsi/gomega" "github.com/codesphere-cloud/oms/cli/cmd/testutil" - "github.com/codesphere-cloud/oms/internal/installer" "github.com/codesphere-cloud/oms/internal/installer/files" "github.com/codesphere-cloud/oms/internal/installer/vault" "github.com/codesphere-cloud/oms/internal/util" @@ -22,7 +21,7 @@ import ( var _ = Describe("ApplyProfile", func() { DescribeTable("profile application", func(profile string, wantErr bool, checkDatacenterName string) { - icg := installer.NewInstallConfigManager() + icg := newPlainInstallConfigManager() err := icg.ApplyProfile(profile) if wantErr { @@ -43,7 +42,7 @@ var _ = Describe("ApplyProfile", func() { Context("dev profile details", func() { It("sets correct dev profile configuration", func() { - icg := installer.NewInstallConfigManager() + icg := newPlainInstallConfigManager() err := icg.ApplyProfile("dev") Expect(err).NotTo(HaveOccurred()) @@ -255,7 +254,7 @@ codesphere: FileWriter: util.NewFilesystemWriter(), } - icg := installer.NewInstallConfigManager() + icg := newSOPSInstallConfigManager() err = c.validateOnly(icg) Expect(err).NotTo(HaveOccurred()) }) @@ -306,7 +305,7 @@ codesphere: FileWriter: util.NewFilesystemWriter(), } - icg := installer.NewInstallConfigManager() + icg := newPlainInstallConfigManager() err = c.validateOnly(icg) Expect(err).To(HaveOccurred()) }) @@ -368,7 +367,7 @@ codesphere: FileWriter: util.NewFilesystemWriter(), } - icg := installer.NewInstallConfigManager() + icg := newPlainInstallConfigManager() err = c.validateOnly(icg) Expect(err).To(HaveOccurred()) }) diff --git a/cli/cmd/install_config_test_helpers_test.go b/cli/cmd/install_config_test_helpers_test.go new file mode 100644 index 00000000..7d320d4b --- /dev/null +++ b/cli/cmd/install_config_test_helpers_test.go @@ -0,0 +1,23 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "github.com/codesphere-cloud/oms/internal/installer" + . "github.com/onsi/gomega" +) + +func newPlainInstallConfigManager() installer.InstallConfigManager { + manager, err := installer.NewInstallConfigManager("plain", "") + Expect(err).NotTo(HaveOccurred()) + + return manager +} + +func newSOPSInstallConfigManager() installer.InstallConfigManager { + manager, err := installer.NewInstallConfigManager("sops", "") + Expect(err).NotTo(HaveOccurred()) + + return manager +} diff --git a/cli/cmd/k0s/install_config_test_helpers_test.go b/cli/cmd/k0s/install_config_test_helpers_test.go new file mode 100644 index 00000000..eeb6aa7e --- /dev/null +++ b/cli/cmd/k0s/install_config_test_helpers_test.go @@ -0,0 +1,17 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +//go:build integration + +package k0s_test + +import ( + "github.com/codesphere-cloud/oms/internal/installer" + . "github.com/onsi/gomega" +) + +func newPlainInstallConfigManager() installer.InstallConfigManager { + manager, err := installer.NewInstallConfigManager("plain", "") + Expect(err).NotTo(HaveOccurred()) + return manager +} diff --git a/cli/cmd/k0s/install_k0s.go b/cli/cmd/k0s/install_k0s.go index 09b95f15..31591a99 100644 --- a/cli/cmd/k0s/install_k0s.go +++ b/cli/cmd/k0s/install_k0s.go @@ -40,6 +40,7 @@ type InstallK0sOpts struct { NoDownload bool Vault string VaultPrivKey string + VaultType string } func (c *InstallK0sCmd) RunE(_ *cobra.Command, args []string) error { @@ -88,6 +89,7 @@ func AddInstallCmd(install *cobra.Command, opts *util.GlobalOptions) { k0s.cmd.Flags().StringVar(&k0s.Opts.Vault, "vault", "", "Path to prod.vault.yaml to save the kubeconfig into (optional)") k0s.cmd.Flags().StringVar(&k0s.Opts.VaultPrivKey, "vault-priv-key", "", "Path to the age private key to decrypt the vault (optional, for SOPS-encrypted vaults)") + k0s.cmd.Flags().StringVar(&k0s.Opts.VaultType, "vault-type", "sops", "Vault storage type (sops or plain)") _ = k0s.cmd.MarkFlagRequired("install-config") @@ -145,18 +147,16 @@ func (c *InstallK0sCmd) InstallK0s(pm installer.PackageManager, k0s installer.K0 } func (c *InstallK0sCmd) loadInstallConfig() (*files.RootConfig, error) { - icg := installer.NewInstallConfigManager() - if err := icg.LoadInstallConfigFromFile(c.Opts.InstallConfig); err != nil { + config, err := installer.NewConfig().ParseConfigYaml(c.Opts.InstallConfig) + if err != nil { return nil, fmt.Errorf("failed to load install-config: %w", err) } - config := icg.GetInstallConfig() - if !config.Kubernetes.ManagedByCodesphere { return nil, fmt.Errorf("install-config specifies external Kubernetes, k0s installation is only supported for Codesphere-managed Kubernetes") } - return config, nil + return &config, nil } func (c *InstallK0sCmd) determineK0sVersion(k0s installer.K0sManager) (string, error) { @@ -261,12 +261,12 @@ func (c *InstallK0sCmd) saveKubeconfigToVault(k0sctl installer.K0sctlManager, k0 }, }) - vaultYAML, err := vault.Marshal() + store, err := c.vaultStore() if err != nil { - return fmt.Errorf("failed to marshal vault: %w", err) + return err } - if err := c.writeEncryptedVault(vaultYAML); err != nil { + if err := store.Save(vault); err != nil { return err } @@ -274,39 +274,24 @@ func (c *InstallK0sCmd) saveKubeconfigToVault(k0sctl installer.K0sctlManager, k0 return nil } -// writeEncryptedVault writes vaultYAML to the vault path, encrypting it with SOPS. -// Uses a temporary file so the original vault is left untouched on failure. -func (c *InstallK0sCmd) writeEncryptedVault(vaultYAML []byte) error { - tmpPath := c.Opts.Vault + ".tmp" - - if err := c.FileWriter.WriteFile(tmpPath, vaultYAML, 0600); err != nil { - return fmt.Errorf("failed to write temporary vault file: %w", err) - } - - recipient, _, err := vault.ResolveAgeKey(c.Opts.VaultPrivKey, "") +func (c *InstallK0sCmd) loadOrCreateVault() (*files.InstallVault, error) { + store, err := c.vaultStore() if err != nil { - _ = c.FileWriter.Remove(tmpPath) - return fmt.Errorf("failed to resolve age key for vault rencryption: %w", err) + return nil, err } - if err := vault.EncryptFileWithSOPS(tmpPath, c.Opts.Vault, recipient); err != nil { - _ = c.FileWriter.Remove(tmpPath) - return fmt.Errorf("failed to encrypt vault file: %w", err) + data, err := store.LoadOrCreate() + if err != nil { + return nil, fmt.Errorf("failed to load vault: %w", err) } - _ = c.FileWriter.Remove(tmpPath) - return nil + return data, nil } -func (c *InstallK0sCmd) loadOrCreateVault() (*files.InstallVault, error) { - if !c.FileWriter.Exists(c.Opts.Vault) { - return &files.InstallVault{}, nil - } - - vault, err := vault.LoadVaultData(c.Opts.Vault, c.Opts.VaultPrivKey) +func (c *InstallK0sCmd) vaultStore() (vault.Vault, error) { + vault, err := vault.NewFromString(c.Opts.VaultType, vault.Options{Path: c.Opts.Vault, AgeKey: c.Opts.VaultPrivKey}) if err != nil { return nil, fmt.Errorf("failed to load vault: %w", err) } - return vault, nil } diff --git a/cli/cmd/k0s/install_k0s_integration_test.go b/cli/cmd/k0s/install_k0s_integration_test.go index c949116c..7e5ec40b 100644 --- a/cli/cmd/k0s/install_k0s_integration_test.go +++ b/cli/cmd/k0s/install_k0s_integration_test.go @@ -80,7 +80,7 @@ var _ = Describe("K0s Install-Config Integration", func() { err = os.WriteFile(configPath, configData, 0644) Expect(err).NotTo(HaveOccurred()) - icg := installer.NewInstallConfigManager() + icg := newPlainInstallConfigManager() err = icg.LoadInstallConfigFromFile(configPath) Expect(err).NotTo(HaveOccurred()) @@ -254,7 +254,7 @@ var _ = Describe("K0s Install-Config Integration", func() { Describe("Error Handling", func() { It("should fail when loading non-existent file", func() { nonExistentPath := filepath.Join(tempDir, "does-not-exist.yaml") - icg := installer.NewInstallConfigManager() + icg := newPlainInstallConfigManager() err := icg.LoadInstallConfigFromFile(nonExistentPath) Expect(err).To(HaveOccurred()) }) @@ -270,7 +270,7 @@ var _ = Describe("K0s Install-Config Integration", func() { err := os.WriteFile(configPath, invalidYAML, 0644) Expect(err).NotTo(HaveOccurred()) - icg := installer.NewInstallConfigManager() + icg := newPlainInstallConfigManager() err = icg.LoadInstallConfigFromFile(configPath) Expect(err).To(HaveOccurred()) }) @@ -279,7 +279,7 @@ var _ = Describe("K0s Install-Config Integration", func() { err := os.WriteFile(configPath, []byte{}, 0644) Expect(err).NotTo(HaveOccurred()) - icg := installer.NewInstallConfigManager() + icg := newPlainInstallConfigManager() err = icg.LoadInstallConfigFromFile(configPath) // Empty file loads successfully but returns empty config Expect(err).NotTo(HaveOccurred()) @@ -386,7 +386,7 @@ var _ = Describe("K0s Install-Config Integration", func() { Expect(err).NotTo(HaveOccurred()) // Reload install-config - icg := installer.NewInstallConfigManager() + icg := newPlainInstallConfigManager() err = icg.LoadInstallConfigFromFile(configPath) Expect(err).NotTo(HaveOccurred()) reloadedInstallConfig := icg.GetInstallConfig() diff --git a/cli/cmd/k0s/install_k0s_test.go b/cli/cmd/k0s/install_k0s_test.go index cb993b5e..7a4bf2c0 100644 --- a/cli/cmd/k0s/install_k0s_test.go +++ b/cli/cmd/k0s/install_k0s_test.go @@ -274,7 +274,9 @@ var _ = Describe("InstallK0sCmd", func() { err = c.InstallK0s(mockPM, mockK0s, mockK0sctl) Expect(err).NotTo(HaveOccurred()) - loaded, err := vault.LoadVaultData(c.Opts.Vault, ageKeyPath) + backend, err := vault.New(vault.TypeSOPS, vault.Options{Path: c.Opts.Vault, AgeKey: ageKeyPath}) + Expect(err).NotTo(HaveOccurred()) + loaded, err := backend.Load() Expect(err).NotTo(HaveOccurred()) secret := loaded.GetSecret(files.SecretKubeConfig) Expect(secret).NotTo(BeNil()) @@ -324,7 +326,9 @@ var _ = Describe("InstallK0sCmd", func() { err = c.InstallK0s(mockPM, mockK0s, mockK0sctl) Expect(err).NotTo(HaveOccurred()) - loaded, err := vault.LoadVaultData(c.Opts.Vault, ageKeyPath) + backend, err := vault.New(vault.TypeSOPS, vault.Options{Path: c.Opts.Vault, AgeKey: ageKeyPath}) + Expect(err).NotTo(HaveOccurred()) + loaded, err := backend.Load() Expect(err).NotTo(HaveOccurred()) Expect(loaded.GetSecret("domainAuthPrivateKey")).NotTo(BeNil(), "pre-existing secret should be preserved") secret := loaded.GetSecret(files.SecretKubeConfig) @@ -374,7 +378,9 @@ var _ = Describe("InstallK0sCmd", func() { err = c.InstallK0s(mockPM, mockK0s, mockK0sctl) Expect(err).NotTo(HaveOccurred()) - loaded, err := vault.LoadVaultData(c.Opts.Vault, ageKeyPath) + backend, err := vault.New(vault.TypeSOPS, vault.Options{Path: c.Opts.Vault, AgeKey: ageKeyPath}) + Expect(err).NotTo(HaveOccurred()) + loaded, err := backend.Load() Expect(err).NotTo(HaveOccurred()) secret := loaded.GetSecret(files.SecretKubeConfig) Expect(secret).NotTo(BeNil()) @@ -401,7 +407,9 @@ var _ = Describe("InstallK0sCmd", func() { err = c.InstallK0s(mockPM, mockK0s, mockK0sctl) Expect(err).NotTo(HaveOccurred()) - loaded, err := vault.LoadVaultData(c.Opts.Vault, ageKeyPath) + backend, err := vault.New(vault.TypeSOPS, vault.Options{Path: c.Opts.Vault, AgeKey: ageKeyPath}) + Expect(err).NotTo(HaveOccurred()) + loaded, err := backend.Load() Expect(err).NotTo(HaveOccurred()) secret := loaded.GetSecret(files.SecretKubeConfig) Expect(secret).NotTo(BeNil()) diff --git a/cli/cmd/template_config.go b/cli/cmd/template_config.go index 48d1010c..e2744a19 100644 --- a/cli/cmd/template_config.go +++ b/cli/cmd/template_config.go @@ -21,9 +21,10 @@ type TemplateConfigCmd struct { type TemplateConfigOpts struct { *util.GlobalOptions - Config string - Vault string - AgeKey string + Config string + Vault string + AgeKey string + VaultType string } func (c *TemplateConfigCmd) RunE(cmd *cobra.Command, _ []string) error { @@ -77,12 +78,12 @@ Secret names and selectors must match entries in the prod.vault.yaml file.`), } configCmd.cmd.Flags().StringVarP(&configCmd.Opts.Config, "config", "c", "", "Path to the config.yaml template to render (required)") - configCmd.cmd.Flags().StringVarP(&configCmd.Opts.Vault, "vault", "v", "", "Path to the SOPS-encrypted prod.vault.yaml file (required)") - configCmd.cmd.Flags().StringVarP(&configCmd.Opts.AgeKey, "age-key", "k", "", "Path to the age key file used to decrypt the vault (required)") + configCmd.cmd.Flags().StringVarP(&configCmd.Opts.Vault, "vault", "v", "", "Path to the prod.vault.yaml file (required)") + configCmd.cmd.Flags().StringVarP(&configCmd.Opts.AgeKey, "age-key", "k", "", "Path to the age key file (required for sops unless an age key environment variable is set)") + configCmd.cmd.Flags().StringVar(&configCmd.Opts.VaultType, "vault-type", "sops", "Vault storage type (sops or plain)") util.MarkFlagRequired(configCmd.cmd, "config") util.MarkFlagRequired(configCmd.cmd, "vault") - util.MarkFlagRequired(configCmd.cmd, "age-key") util.AddCmd(parentCmd, configCmd.cmd) @@ -95,7 +96,12 @@ func (c *TemplateConfigCmd) Render() ([]byte, error) { return nil, fmt.Errorf("failed to read config file %s: %w", c.Opts.Config, err) } - store := vault.NewLazyVaultTemplatingSecretStore(c.Opts.Vault, c.Opts.AgeKey) + backend, err := vault.NewFromString(c.Opts.VaultType, vault.Options{Path: c.Opts.Vault, AgeKey: c.Opts.AgeKey}) + if err != nil { + return nil, fmt.Errorf("failed to load vault: %w", err) + } + + store := vault.NewLazyVaultTemplatingSecretStoreWithVault(backend) rendered, err := configtemplating.RenderInstallConfigTemplate(data, store) if err != nil { return nil, fmt.Errorf("failed to render config template: %w", err) diff --git a/cli/cmd/update_install_config.go b/cli/cmd/update_install_config.go index 26551adf..b42e9e3c 100644 --- a/cli/cmd/update_install_config.go +++ b/cli/cmd/update_install_config.go @@ -28,6 +28,8 @@ type UpdateInstallConfigOpts struct { ConfigFile string VaultFile string + VaultType string + AgeKey string WithComments bool @@ -66,7 +68,10 @@ type UpdateInstallConfigOpts struct { } func (c *UpdateInstallConfigCmd) RunE(_ *cobra.Command, args []string) error { - icg := installer.NewInstallConfigManager() + icg, err := installer.NewInstallConfigManager(c.Opts.VaultType, c.Opts.AgeKey) + if err != nil { + return fmt.Errorf("failed to initialize config manager: %w", err) + } return c.UpdateInstallConfig(icg) } @@ -99,6 +104,8 @@ func AddUpdateInstallConfigCmd(update *cobra.Command, opts *util.GlobalOptions) c.cmd.Flags().StringVarP(&c.Opts.ConfigFile, "config", "c", "config.yaml", "Path to existing config.yaml file") c.cmd.Flags().StringVar(&c.Opts.VaultFile, "vault", "prod.vault.yaml", "Path to existing prod.vault.yaml file") + c.cmd.Flags().StringVar(&c.Opts.VaultType, "vault-type", "sops", "Vault storage type (sops or plain)") + c.cmd.Flags().StringVar(&c.Opts.AgeKey, "age-key", "", "Path to the age private key (required for sops unless SOPS_AGE_KEY or SOPS_AGE_KEY_FILE is set)") c.cmd.Flags().BoolVar(&c.Opts.WithComments, "with-comments", false, "Add helpful comments to the generated YAML files") diff --git a/cli/cmd/update_install_config_test.go b/cli/cmd/update_install_config_test.go index 9fb6bb31..930c3acf 100644 --- a/cli/cmd/update_install_config_test.go +++ b/cli/cmd/update_install_config_test.go @@ -15,7 +15,6 @@ import ( "github.com/codesphere-cloud/oms/cli/cmd/testutil" "github.com/codesphere-cloud/oms/cli/cmd/util" - "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" @@ -228,7 +227,7 @@ codesphere: opts.PostgresPrimaryIP = "10.10.0.4" opts.PostgresServer = "new-postgres-primary" - icg := installer.NewInstallConfigManager() + icg := newSOPSInstallConfigManager() err := cmd.UpdateInstallConfig(icg) Expect(err).NotTo(HaveOccurred()) @@ -238,7 +237,9 @@ codesphere: Expect(icg.GetVault().GetSecret(files.SecretPostgresPrimaryServerKeyPem)).NotTo(BeNil()) Expect(config.Postgres.Primary.SSLConfig.ServerCertPem).NotTo(BeEmpty()) - updatedVault, err := vault.LoadVaultData(vaultFile.Name(), "") + backend, err := vault.New(vault.TypeSOPS, vault.Options{Path: vaultFile.Name()}) + Expect(err).NotTo(HaveOccurred()) + updatedVault, err := backend.Load() Expect(err).NotTo(HaveOccurred()) Expect(updatedVault.GetSecret(files.SecretPostgresPrimaryServerKeyPem)).NotTo(BeNil()) }) @@ -247,7 +248,7 @@ codesphere: opts.PostgresReplicaIP = "10.10.0.7" opts.PostgresReplicaName = "new_replica" - icg := installer.NewInstallConfigManager() + icg := newSOPSInstallConfigManager() err := cmd.UpdateInstallConfig(icg) Expect(err).NotTo(HaveOccurred()) @@ -267,7 +268,7 @@ codesphere: opts.CodespherePublicIP = "203.0.113.100" opts.KubernetesPodCIDR = "10.244.0.0/16" - icg := installer.NewInstallConfigManager() + icg := newSOPSInstallConfigManager() err := cmd.UpdateInstallConfig(icg) Expect(err).NotTo(HaveOccurred()) @@ -289,7 +290,7 @@ codesphere: opts.KubernetesPodCIDR = "100.96.0.0/11" opts.KubernetesServiceCIDR = "100.64.0.0/13" - icg := installer.NewInstallConfigManager() + icg := newSOPSInstallConfigManager() err := cmd.UpdateInstallConfig(icg) Expect(err).NotTo(HaveOccurred()) @@ -305,7 +306,7 @@ codesphere: opts.ClusterGatewayServiceType = "NodePort" opts.ClusterGatewayIPAddresses = []string{"192.168.1.200", "192.168.1.201"} - icg := installer.NewInstallConfigManager() + icg := newSOPSInstallConfigManager() err := cmd.UpdateInstallConfig(icg) Expect(err).NotTo(HaveOccurred()) @@ -321,7 +322,7 @@ codesphere: opts.CodesphereDNSServers = []string{"1.1.1.1", "1.0.0.1"} opts.CodesphereWorkspaceHostingBaseDomain = "workspaces.updated.example.com" - icg := installer.NewInstallConfigManager() + icg := newSOPSInstallConfigManager() err := cmd.UpdateInstallConfig(icg) Expect(err).NotTo(HaveOccurred()) @@ -336,7 +337,7 @@ codesphere: It("should update Ceph nodes subnet", func() { opts.CephNodesSubnet = "10.53.102.0/24" - icg := installer.NewInstallConfigManager() + icg := newSOPSInstallConfigManager() err := cmd.UpdateInstallConfig(icg) Expect(err).NotTo(HaveOccurred()) @@ -356,7 +357,7 @@ codesphere: It("should return an error", func() { opts.ConfigFile = "/nonexistent/config.yaml" - icg := installer.NewInstallConfigManager() + icg := newSOPSInstallConfigManager() err := cmd.UpdateInstallConfig(icg) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("failed to load config file")) @@ -367,7 +368,7 @@ codesphere: It("should return an error", func() { opts.VaultFile = "/nonexistent/vault.yaml" - icg := installer.NewInstallConfigManager() + icg := newSOPSInstallConfigManager() err := cmd.UpdateInstallConfig(icg) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("failed to load vault file")) @@ -393,11 +394,13 @@ codesphere: } opts.CodesphereDomain = "updated.example.com" - icg := installer.NewInstallConfigManager() + icg := newSOPSInstallConfigManager() err = cmd.UpdateInstallConfig(icg) Expect(err).NotTo(HaveOccurred()) - updatedVault, err := vault.LoadVaultData(vaultFile.Name(), "") + backend, err := vault.New(vault.TypeSOPS, vault.Options{Path: vaultFile.Name()}) + Expect(err).NotTo(HaveOccurred()) + updatedVault, err := backend.Load() Expect(err).NotTo(HaveOccurred()) // Verify all initial secrets are still present with the same values @@ -424,11 +427,13 @@ codesphere: } opts.PostgresPrimaryIP = "10.20.0.10" - icg := installer.NewInstallConfigManager() + icg := newSOPSInstallConfigManager() err = cmd.UpdateInstallConfig(icg) Expect(err).NotTo(HaveOccurred()) - updatedVault, err := vault.LoadVaultData(vaultFile.Name(), "") + backend, err := vault.New(vault.TypeSOPS, vault.Options{Path: vaultFile.Name()}) + Expect(err).NotTo(HaveOccurred()) + updatedVault, err := backend.Load() Expect(err).NotTo(HaveOccurred()) // Verify all initial secrets are still present with the same values diff --git a/docs/oms_beta.md b/docs/oms_beta.md index 3e19c4f2..4c0386d5 100644 --- a/docs/oms_beta.md +++ b/docs/oms_beta.md @@ -20,5 +20,5 @@ Be aware that that usage and behavior may change as the features are developed. * [oms beta bootstrap-local](oms_beta_bootstrap-local.md) - Bootstrap a local Codesphere environment * [oms beta extend](oms_beta_extend.md) - Extend Codesphere ressources such as base images. * [oms beta install](oms_beta_install.md) - Install beta components -* [oms beta vault-secret](oms_beta_vault-secret.md) - Create a Kubernetes secret from a SOPS-encrypted vault file +* [oms beta vault-secret](oms_beta_vault-secret.md) - Create a Kubernetes secret from a vault file diff --git a/docs/oms_beta_vault-secret.md b/docs/oms_beta_vault-secret.md index 268a8d0d..d05e1257 100644 --- a/docs/oms_beta_vault-secret.md +++ b/docs/oms_beta_vault-secret.md @@ -1,11 +1,11 @@ ## oms beta vault-secret -Create a Kubernetes secret from a SOPS-encrypted vault file +Create a Kubernetes secret from a vault file ### Synopsis -Create a Kubernetes secret from a SOPS-encrypted prod.vault.yaml file. -Reads the encrypted vault file, decrypts it using the age key, and creates a Kubernetes secret +Create a Kubernetes secret from a prod.vault.yaml file. +Loads the selected vault type and creates a Kubernetes secret with all the vault entries as key-value pairs in the target cluster. ``` @@ -26,11 +26,12 @@ $ oms vault-secret --vault-file prod.vault.yaml --age-key /path/to/age_key.txt - ### Options ``` - --age-key string Path to the age key file (optional, will use defaults if not provided) + --age-key string Path to the age key file (required for sops unless an age key environment variable is set) -h, --help help for vault-secret --namespace string Kubernetes namespace where the secret will be created (default "codesphere") --secret-name string Name of the Kubernetes secret to create (default "cs-vault") - --vault-file string Path to the SOPS-encrypted vault file (required) + --vault-file string Path to the vault file (required) + --vault-type string Vault storage type (sops or plain) (default "sops") ``` ### SEE ALSO diff --git a/docs/oms_init_install-config.md b/docs/oms_init_install-config.md index 870b87c7..463667d7 100644 --- a/docs/oms_init_install-config.md +++ b/docs/oms_init_install-config.md @@ -57,6 +57,7 @@ $ oms init install-config --validate -c config.yaml --vault prod.vault.yaml --acme-enabled Enable ACME certificate issuer --acme-issuer-name string Name for the ACME ClusterIssuer (default "acme-issuer") --acme-server string ACME server URL (default "https://acme-v02.api.letsencrypt.org/directory") + --age-key string Path to the age private key (required for sops unless SOPS_AGE_KEY or SOPS_AGE_KEY_FILE is set) --ansible-inventory string Path to Ansible inventory file to import host information from --ceph-csi-kubelet-dir string Directory of kubelet for ceph csi. Required for some cloud providers --ceph-nodes-subnet string CIDR subnet for ceph nodes @@ -83,6 +84,7 @@ $ oms init install-config --validate -c config.yaml --vault prod.vault.yaml --secrets-dir string Secrets base directory (default "/root/secrets") --validate Validate existing config files instead of creating new ones --vault string Output file path for prod.vault.yaml (default "prod.vault.yaml") + --vault-type string Vault storage type (sops or plain) (default "sops") --with-comments Add helpful comments to the generated YAML files ``` diff --git a/docs/oms_install_codesphere.md b/docs/oms_install_codesphere.md index dcf6c214..8c6488ce 100644 --- a/docs/oms_install_codesphere.md +++ b/docs/oms_install_codesphere.md @@ -38,9 +38,9 @@ $ oms install codesphere -p codesphere-v1.2.3-installer-lite.tar.gz -k 0 { + t = forcedType[0] } + + vault, err := vault.New(t, vault.Options{Path: path, AgeKey: g.vaultAgeKey, WithComments: comments, FileIO: g.fileIO}) + if err != nil { + return nil, fmt.Errorf("failed to read vault: %w", err) + } + + return vault, nil } func (g *InstallConfig) LoadInstallConfigFromFile(configPath string) error { @@ -114,26 +115,34 @@ func (g *InstallConfig) LoadInstallConfigFromFile(configPath string) error { return nil } -// LoadVaultFromFile loads the vault content from an encrypted file into the installConfig -// Returns an error if age key file has not been set as environment variable SOPS_AGE_KEY_FILE +// LoadVaultFromFile loads vault content using the manager's configured backend. +// An empty type selects the default SOPS backend. func (g *InstallConfig) LoadVaultFromFile(vaultPath string) error { - vault, err := vault.LoadVaultData(vaultPath, "") + store, err := g.vaultStore(vaultPath, false) if err != nil { - return err + return fmt.Errorf("failed to initialize vault backend: %w", err) } - g.Vault = vault + loaded, err := store.Load() + if err != nil { + return fmt.Errorf("failed to load vault: %w", err) + } + + g.Vault = loaded return nil } // LoadVaultFromUnecryptedFile loads the vault content from an unencrypted file into the installConfig func (g *InstallConfig) LoadVaultFromUnecryptedFile(vaultPath string) error { - vault, err := vault.LoadUnencryptedVaultData(vaultPath) + store, err := g.vaultStore(vaultPath, false, vault.TypePlain) if err != nil { - return err + return fmt.Errorf("failed to initialize vault backend: %w", err) } - g.Vault = vault + g.Vault, err = store.LoadOrCreate() + if err != nil { + return fmt.Errorf("failed to load vault: %w", err) + } return nil } @@ -304,83 +313,19 @@ 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)) + store, err := g.vaultStore(vaultPath, withComments) if err != nil { - return fmt.Errorf("failed to resolve age key: %w", err) + return fmt.Errorf("failed to initialize vault backend: %w", err) } - plainPath, err := g.fileIO.CreateTemp(filepath.Dir(vaultPath), "."+filepath.Base(vaultPath)+".plaintext-*") + err = store.Save(g.Vault) 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 fmt.Errorf("failed to write vault: %w", err) } - return nil } -func (g *InstallConfig) marshalVault(vaultPath string, withComments bool) ([]byte, error) { - if g.Config == nil { - return nil, fmt.Errorf("no configuration provided - config is nil") - } - if g.Vault == nil { - g.Vault = &files.InstallVault{} - } - - vaultYAML, err := g.Vault.Marshal() - if err != nil { - return nil, fmt.Errorf("failed to marshal %s: %w", filepath.Base(vaultPath), err) - } - - if withComments { - vaultYAML = AddVaultComments(vaultYAML) - } - - return vaultYAML, nil -} - func AddConfigComments(yamlData []byte) []byte { header := `# Codesphere Installer Configuration # Generated by OMS CLI diff --git a/internal/installer/config_manager_ansible_test.go b/internal/installer/config_manager_ansible_test.go index 7d9d374f..6776f7d8 100644 --- a/internal/installer/config_manager_ansible_test.go +++ b/internal/installer/config_manager_ansible_test.go @@ -22,7 +22,7 @@ var _ = Describe("ConfigManagerAnsible", func() { ) BeforeEach(func() { - manager = installer.NewInstallConfigManager() + manager = newPlainInstallConfigManager() tempDir = GinkgoT().TempDir() inventoryFilePath = filepath.Join(tempDir, "inventory.yaml") diff --git a/internal/installer/config_manager_profile_test.go b/internal/installer/config_manager_profile_test.go index 23fd2eb2..5923ff60 100644 --- a/internal/installer/config_manager_profile_test.go +++ b/internal/installer/config_manager_profile_test.go @@ -16,7 +16,7 @@ var _ = Describe("ConfigManagerProfile", func() { var manager installer.InstallConfigManager BeforeEach(func() { - manager = installer.NewInstallConfigManager() + manager = newPlainInstallConfigManager() }) Describe("ApplyProfile", func() { @@ -161,9 +161,9 @@ var _ = Describe("ConfigManagerProfile", func() { Context("profile-specific differences", func() { It("should have the expected datacenter names", func() { - devManager := installer.NewInstallConfigManager() - prodManager := installer.NewInstallConfigManager() - minimalManager := installer.NewInstallConfigManager() + devManager := newPlainInstallConfigManager() + prodManager := newPlainInstallConfigManager() + minimalManager := newPlainInstallConfigManager() err := devManager.ApplyProfile(installer.PROFILE_DEV) Expect(err).ToNot(HaveOccurred()) @@ -178,9 +178,9 @@ var _ = Describe("ConfigManagerProfile", func() { }) It("should have different resource profiles", func() { - devManager := installer.NewInstallConfigManager() - prodManager := installer.NewInstallConfigManager() - minimalManager := installer.NewInstallConfigManager() + devManager := newPlainInstallConfigManager() + prodManager := newPlainInstallConfigManager() + minimalManager := newPlainInstallConfigManager() err := devManager.ApplyProfile(installer.PROFILE_DEV) Expect(err).ToNot(HaveOccurred()) diff --git a/internal/installer/config_manager_secrets_test.go b/internal/installer/config_manager_secrets_test.go index 993fb4f2..20d8f276 100644 --- a/internal/installer/config_manager_secrets_test.go +++ b/internal/installer/config_manager_secrets_test.go @@ -17,10 +17,8 @@ var _ = Describe("GenerateSecrets", func() { var mgr *installer.InstallConfig BeforeEach(func() { - mgr = &installer.InstallConfig{ - Config: &files.RootConfig{}, - Vault: &files.InstallVault{}, - } + mgr = newPlainInstallConfigManager().(*installer.InstallConfig) + mgr.Config = &files.RootConfig{} }) Context("with basic configuration (no postgres)", func() { @@ -187,10 +185,8 @@ var _ = Describe("GenerateSecrets", func() { Context("uniqueness", func() { It("generates different secrets for different instances", func() { - mgr2 := &installer.InstallConfig{ - Config: &files.RootConfig{}, - Vault: &files.InstallVault{}, - } + mgr2 := newPlainInstallConfigManager().(*installer.InstallConfig) + mgr2.Config = &files.RootConfig{} Expect(mgr.GenerateSecrets()).To(Succeed()) Expect(mgr2.GenerateSecrets()).To(Succeed()) diff --git a/internal/installer/config_manager_test.go b/internal/installer/config_manager_test.go index 3b6830b8..e276648c 100644 --- a/internal/installer/config_manager_test.go +++ b/internal/installer/config_manager_test.go @@ -5,7 +5,6 @@ package installer_test import ( "bytes" - "errors" "os" "path/filepath" @@ -15,7 +14,6 @@ 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 { @@ -137,14 +135,13 @@ var _ = Describe("ConfigManager", func() { ) BeforeEach(func() { - configManager = &installer.InstallConfig{ - Config: &files.RootConfig{}, - } + manager := newPlainInstallConfigManager() + configManager = manager.(*installer.InstallConfig) }) Describe("NewInstallConfigManager", func() { It("should create a new config manager", func() { - manager := installer.NewInstallConfigManager() + manager := newPlainInstallConfigManager() Expect(manager).ToNot(BeNil()) }) }) @@ -546,39 +543,11 @@ var _ = Describe("ConfigManager", func() { }) Describe("WriteVault", func() { - It("should return error if config is nil", func() { + It("writes independently of the install config", func() { configManager.Config = nil - err := configManager.WriteVault("/tmp/vault.yaml", false) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("no configuration provided")) - }) - }) - - 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)) + vaultPath := filepath.Join(GinkgoT().TempDir(), "vault.yaml") + Expect(configManager.WriteVault(vaultPath, false)).To(Succeed()) + Expect(vaultPath).To(BeAnExistingFile()) }) }) @@ -609,9 +578,8 @@ var _ = Describe("ConfigManager", func() { }) Context("vault deduplication on re-write", func() { - It("should not produce duplicate vault entries when WriteUnencryptedVault is called after loading existing vault", func() { - mockIO := NewMockFileIO() - configManager.SetFileIO(mockIO) + It("should not produce duplicate vault entries when WriteVault is called after loading existing vault", func() { + vaultPath := filepath.Join(GinkgoT().TempDir(), "vault.yaml") err := configManager.ApplyProfile("prod") Expect(err).ToNot(HaveOccurred()) @@ -619,11 +587,12 @@ var _ = Describe("ConfigManager", func() { err = configManager.GenerateSecrets() Expect(err).ToNot(HaveOccurred()) - // First write via WriteUnencryptedVault - err = configManager.WriteUnencryptedVault("/tmp/vault.yaml", false) + // First write via WriteVault + err = configManager.WriteVault(vaultPath, false) Expect(err).ToNot(HaveOccurred()) - firstVaultBytes := mockIO.GetFileContent("/tmp/vault.yaml") + firstVaultBytes, err := os.ReadFile(vaultPath) + Expect(err).NotTo(HaveOccurred()) Expect(firstVaultBytes).ToNot(BeEmpty()) // Load the written vault back @@ -633,10 +602,11 @@ var _ = Describe("ConfigManager", func() { configManager.Vault = vault // Re-write vault (simulating a second run) - err = configManager.WriteUnencryptedVault("/tmp/vault.yaml", false) + err = configManager.WriteVault(vaultPath, false) Expect(err).ToNot(HaveOccurred()) - secondVaultBytes := mockIO.GetFileContent("/tmp/vault.yaml") + secondVaultBytes, err := os.ReadFile(vaultPath) + Expect(err).NotTo(HaveOccurred()) Expect(secondVaultBytes).To(Equal(firstVaultBytes), "serialized vault should be identical after load and re-write") }) @@ -647,6 +617,9 @@ var _ = Describe("ConfigManager", func() { mockIO := NewMockFileIO() configManager.SetFileIO(mockIO) + vaultPath := filepath.Join(GinkgoT().TempDir(), "vault.yaml") + vaultPath2 := filepath.Join(GinkgoT().TempDir(), "vault2.yaml") + // --- First run: generate everything from scratch --- err := configManager.ApplyProfile("prod") Expect(err).ToNot(HaveOccurred()) @@ -672,11 +645,11 @@ var _ = Describe("ConfigManager", func() { // Write config and vault err = configManager.WriteInstallConfig("/tmp/config.yaml", false) Expect(err).ToNot(HaveOccurred()) - err = configManager.WriteUnencryptedVault("/tmp/vault.yaml", false) + err = configManager.WriteVault(vaultPath, false) Expect(err).ToNot(HaveOccurred()) // --- Second run: simulate loading existing files --- - configManager2 := &installer.InstallConfig{} + configManager2 := newPlainInstallConfigManager().(*installer.InstallConfig) configManager2.SetFileIO(mockIO) // Reload config from written YAML @@ -691,7 +664,8 @@ var _ = Describe("ConfigManager", func() { "cert should be in config.yaml") // Reload vault from written YAML - vaultBytes := mockIO.GetFileContent("/tmp/vault.yaml") + vaultBytes, err := mockIO.ReadFile(vaultPath) + Expect(err).NotTo(HaveOccurred()) Expect(vaultBytes).ToNot(BeNil()) vault2 := &files.InstallVault{} err = vault2.Unmarshal(vaultBytes) @@ -712,12 +686,13 @@ var _ = Describe("ConfigManager", func() { Expect(err).ToNot(HaveOccurred(), "cert/key should match after load from vault") // Write vault again - err = configManager2.WriteUnencryptedVault("/tmp/vault2.yaml", false) + err = configManager2.WriteVault(vaultPath2, false) Expect(err).ToNot(HaveOccurred()) // Verify no duplicates in re-written vault vault3 := &files.InstallVault{} - vaultBytes2 := mockIO.GetFileContent("/tmp/vault2.yaml") + vaultBytes2, err := mockIO.ReadFile(vaultPath2) + Expect(err).NotTo(HaveOccurred()) err = vault3.Unmarshal(vaultBytes2) Expect(err).ToNot(HaveOccurred()) diff --git a/internal/installer/config_manager_test_helpers_test.go b/internal/installer/config_manager_test_helpers_test.go new file mode 100644 index 00000000..9c812f4e --- /dev/null +++ b/internal/installer/config_manager_test_helpers_test.go @@ -0,0 +1,16 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer_test + +import ( + "github.com/codesphere-cloud/oms/internal/installer" + . "github.com/onsi/gomega" +) + +func newPlainInstallConfigManager() installer.InstallConfigManager { + manager, err := installer.NewInstallConfigManager("plain", "") + Expect(err).NotTo(HaveOccurred()) + + return manager +} diff --git a/internal/installer/config_template_test.go b/internal/installer/config_template_test.go index c314a8a6..7050b9fc 100644 --- a/internal/installer/config_template_test.go +++ b/internal/installer/config_template_test.go @@ -128,7 +128,9 @@ codesphere: Expect(err).NotTo(HaveOccurred()) Expect(os.WriteFile(vaultPath, vaultYaml, 0600)).To(Succeed()) - _, err = vault.LoadVaultData(vaultPath, "") + backend, err := vault.New(vault.TypeSOPS, vault.Options{Path: vaultPath, AgeKey: filepath.Join(tempDir, "unused-age-key")}) + Expect(err).NotTo(HaveOccurred()) + _, err = backend.Load() Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("is not SOPS-encrypted")) diff --git a/internal/installer/mocks.go b/internal/installer/mocks.go index 34455727..3de55b40 100644 --- a/internal/installer/mocks.go +++ b/internal/installer/mocks.go @@ -753,63 +753,6 @@ 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 deleted file mode 100644 index 75bd5c16..00000000 --- a/internal/installer/vault/mocks.go +++ /dev/null @@ -1,198 +0,0 @@ -// 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/plain_vault.go b/internal/installer/vault/plain_vault.go new file mode 100644 index 00000000..e4bd81c0 --- /dev/null +++ b/internal/installer/vault/plain_vault.go @@ -0,0 +1,72 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package vault handles all interactions with vaults across vault types. Currently supported types are plain and sops +package vault + +import ( + "errors" + "fmt" + "io/fs" + "strings" + + "github.com/codesphere-cloud/oms/internal/installer/files" +) + +// PlainFileVault stores vault YAML as an unencrypted file. +type PlainFileVault struct{ options FileOptions } + +// NewPlainFileVault returns a vault handler for interacting with a plain (unencrypted) file +func NewPlainFileVault(opts FileOptions) (*PlainFileVault, error) { + if strings.TrimSpace(opts.Path) == "" { + return nil, fmt.Errorf("plain vault requires a file path") + } + + opts.FileIO = fileIOOrDefault(opts.FileIO) + + return &PlainFileVault{options: opts}, nil +} + +// Load reads the unencrypted vault file and verifies it's not accidentally SOPS-encrypted +func (v *PlainFileVault) Load() (*files.InstallVault, error) { + data, err := v.options.FileIO.ReadFile(v.options.Path) + if err != nil { + return nil, fmt.Errorf("failed to read vault file %s: %w", v.options.Path, err) + } + + encrypted, err := isSOPSEncryptedYAML(data) + if err != nil { + return nil, fmt.Errorf("failed to inspect vault file %s: %w", v.options.Path, err) + } + + if encrypted { + return nil, fmt.Errorf("vault file %s is SOPS-encrypted, but vault type is %q", v.options.Path, TypePlain) + } + + result, err := parseVaultData(data) + if err != nil { + return nil, fmt.Errorf("failed to parse vault file %s: %w", v.options.Path, err) + } + + return result, nil +} + +// LoadOrCreate reads the vault from disk and returns an empty vault object if it doesn't exist +func (v *PlainFileVault) LoadOrCreate() (*files.InstallVault, error) { + result, err := v.Load() + if errors.Is(err, fs.ErrNotExist) { + return &files.InstallVault{}, nil + } + + return result, err +} + +// Save writes the vault data to an unencrypted file +func (v *PlainFileVault) Save(data *files.InstallVault) error { + plain, err := marshalVault(data, v.options.WithComments) + if err != nil { + return err + } + + return writeVaultFile(v.options.FileIO, v.options.Path, plain) +} diff --git a/internal/installer/vault/sops_vault.go b/internal/installer/vault/sops_vault.go new file mode 100644 index 00000000..5108a53c --- /dev/null +++ b/internal/installer/vault/sops_vault.go @@ -0,0 +1,183 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package vault + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/codesphere-cloud/oms/internal/installer/files" + "github.com/codesphere-cloud/oms/internal/util" + sopsage "github.com/getsops/sops/v3/age" +) + +// SOPSVault stores vault YAML encrypted with SOPS and age. +type SOPSVault struct{ options SOPSOptions } + +// SOPSOptions contains configuration specific to the SOPS file backend. +type SOPSOptions struct { + File FileOptions + AgeKey string +} + +// NewSOPSVault creates a new vault handler for interacting with SOPS-encrypted vault files +func NewSOPSVault(opts SOPSOptions) (*SOPSVault, error) { + if strings.TrimSpace(opts.File.Path) == "" { + return nil, fmt.Errorf("SOPS vault requires a file path") + } + + if err := ValidateConfiguration(TypeSOPS, opts.AgeKey); err != nil { + return nil, err + } + + opts.File.FileIO = fileIOOrDefault(opts.File.FileIO) + + return &SOPSVault{options: opts}, nil +} + +// Load reads the validates the referenced file is SOPS-encrypted and load its data +func (v *SOPSVault) Load() (*files.InstallVault, error) { + keyPath, err := v.getAgeKey() + if err != nil { + return nil, err + } + + data, err := v.options.File.FileIO.ReadFile(v.options.File.Path) + if err != nil { + return nil, fmt.Errorf("failed to read vault file %s: %w", v.options.File.Path, err) + } + + encrypted, err := isSOPSEncryptedYAML(data) + if err != nil { + return nil, fmt.Errorf("failed to inspect vault file %s: %w", v.options.File.Path, err) + } + + if !encrypted { + return nil, fmt.Errorf("vault file %s is not SOPS-encrypted", v.options.File.Path) + } + + plain, err := DecryptFileWithSOPS(v.options.File.Path, keyPath) + if err != nil { + return nil, fmt.Errorf("failed to decrypt vault file %s: %w", v.options.File.Path, err) + } + + result, err := parseVaultData(plain) + if err != nil { + return nil, fmt.Errorf("failed to parse decrypted vault file %s: %w", v.options.File.Path, err) + } + + return result, nil +} + +// LoadOrCreate loads the referenced file from disk or returns an empty InstallVault +func (v *SOPSVault) LoadOrCreate() (*files.InstallVault, error) { + result, err := v.Load() + if errors.Is(err, fs.ErrNotExist) { + return &files.InstallVault{}, nil + } + + return result, err +} + +// Save encrypts the vault data and writes it to the configured path +func (v *SOPSVault) Save(data *files.InstallVault) error { + if _, err := v.getAgeKey(); err != nil { + return err + } + + recipient, _, err := resolveConfiguredAgeKey(v.options.File.FileIO, v.options.AgeKey) + if err != nil { + return err + } + + plain, err := marshalVault(data, v.options.File.WithComments) + if err != nil { + return err + } + + if err := v.options.File.FileIO.MkdirAll(filepath.Dir(v.options.File.Path), 0700); err != nil { + return fmt.Errorf("failed to create vault directory: %w", err) + } + + plainPath, err := v.options.File.FileIO.CreateTemp(filepath.Dir(v.options.File.Path), ".vault-plaintext-*") + if err != nil { + return fmt.Errorf("failed to create temporary plaintext vault: %w", err) + } + defer func() { _ = v.options.File.FileIO.Remove(plainPath) }() + + if err := v.options.File.FileIO.WriteFile(plainPath, plain, 0600); err != nil { + return fmt.Errorf("failed to write temporary plaintext vault: %w", err) + } + + encryptedPath, err := v.options.File.FileIO.CreateTemp(filepath.Dir(v.options.File.Path), ".vault-encrypted-*") + if err != nil { + return fmt.Errorf("failed to create temporary encrypted vault: %w", err) + } + defer func() { _ = v.options.File.FileIO.Remove(encryptedPath) }() + + if err := EncryptFileWithSOPS(plainPath, encryptedPath, recipient); err != nil { + return err + } + + if err := v.options.File.FileIO.Chmod(encryptedPath, 0600); err != nil { + return fmt.Errorf("failed to set encrypted vault permissions: %w", err) + } + + if err := v.options.File.FileIO.Rename(encryptedPath, v.options.File.Path); err != nil { + return fmt.Errorf("failed to replace encrypted vault: %w", err) + } + + return nil +} + +func (v *SOPSVault) getAgeKey() (string, error) { + if v.options.AgeKey != "" { + return v.options.AgeKey, nil + } + + if os.Getenv(sopsage.SopsAgeKeyEnv) != "" { + return "", nil + } + + if keyFile := os.Getenv(sopsage.SopsAgeKeyFileEnv); keyFile != "" { + return keyFile, nil + } + + return "", ValidateConfiguration(TypeSOPS, "") +} + +func resolveConfiguredAgeKey(fileIO util.FileIO, explicit string) (recipient, keyPath string, err error) { + if explicit != "" { + recipient, err := readRecipientFromFile(fileIO, explicit) + if err != nil { + return "", "", fmt.Errorf("failed to read age key from %s: %w", explicit, err) + } + + return recipient, explicit, nil + } + + if raw := os.Getenv(sopsage.SopsAgeKeyEnv); raw != "" { + recipient, err := parseAgeRecipient(strings.NewReader(raw)) + if err != nil { + return "", "", fmt.Errorf("failed to parse age key from %s: %w", sopsage.SopsAgeKeyEnv, err) + } + + return recipient, "", nil + } + + if keyFile := os.Getenv(sopsage.SopsAgeKeyFileEnv); keyFile != "" { + recipient, err := readRecipientFromFile(fileIO, keyFile) + if err != nil { + return "", "", fmt.Errorf("failed to read age key from %s: %w", keyFile, err) + } + + return recipient, keyFile, nil + } + + return "", "", fmt.Errorf("SOPS vault requires an age key") +} diff --git a/internal/installer/vault/vault.go b/internal/installer/vault/vault.go new file mode 100644 index 00000000..807eea1c --- /dev/null +++ b/internal/installer/vault/vault.go @@ -0,0 +1,151 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package vault + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/codesphere-cloud/oms/internal/installer/files" + "github.com/codesphere-cloud/oms/internal/util" + sopsage "github.com/getsops/sops/v3/age" +) + +// Type identifies the on-disk vault format. +type Type string + +// Supported Vault types +const ( + TypeSOPS Type = "sops" + TypePlain Type = "plain" + DefaultType = TypeSOPS +) + +// Vault is the persistence boundary for installer secrets. Callers work with +// InstallVault values and do not need to know how those values are represented +// or protected on disk. +type Vault interface { + Load() (*files.InstallVault, error) + LoadOrCreate() (*files.InstallVault, error) + Save(*files.InstallVault) error +} + +// Options contains the parameters currently accepted by the vault factory. +// The factory only forwards file parameters to implementations that use them; +// future non-file vaults can ignore Path and WithComments entirely. +type Options struct { + Path string + AgeKey string + WithComments bool + FileIO util.FileIO +} + +// FileOptions contains configuration shared by file-backed vaults. It is kept +// out of the Vault interface so non-file implementations do not inherit file +// concepts. +type FileOptions struct { + Path string + WithComments bool + FileIO util.FileIO +} + +// ParseType validates a user supplied vault type. +func ParseType(value string) (Type, error) { + switch Type(strings.ToLower(strings.TrimSpace(value))) { + case "", TypeSOPS: + return TypeSOPS, nil + case TypePlain: + return TypePlain, nil + default: + return "", fmt.Errorf("unsupported vault type %q (must be %q or %q)", value, TypeSOPS, TypePlain) + } +} + +// ValidateConfiguration validates backend-specific, non-resource parameters. +// File paths are intentionally validated by the file-backed constructors. +func ValidateConfiguration(vaultType Type, ageKey string) error { + if vaultType != TypeSOPS { + return nil + } + + if ageKey != "" || os.Getenv(sopsage.SopsAgeKeyEnv) != "" || os.Getenv(sopsage.SopsAgeKeyFileEnv) != "" { + return nil + } + + return fmt.Errorf("SOPS vault requires an age key; set an age key argument or %s/%s", sopsage.SopsAgeKeyEnv, sopsage.SopsAgeKeyFileEnv) +} + +// New creates a vault implementation for the requested type. +func New(vaultType Type, opts Options) (Vault, error) { + switch vaultType { + case TypeSOPS: + return NewSOPSVault(SOPSOptions{ + File: FileOptions{Path: opts.Path, WithComments: opts.WithComments, FileIO: opts.FileIO}, + AgeKey: opts.AgeKey, + }) + case TypePlain: + return NewPlainFileVault(FileOptions{Path: opts.Path, WithComments: opts.WithComments, FileIO: opts.FileIO}) + default: + return nil, fmt.Errorf("unsupported vault type %q", vaultType) + } +} + +// NewFromString parses vaultType and creates the matching implementation. +func NewFromString(vaultType string, opts Options) (Vault, error) { + t, err := ParseType(vaultType) + if err != nil { + return nil, err + } + + return New(t, opts) +} + +func marshalVault(data *files.InstallVault, comments bool) ([]byte, error) { + if data == nil { + data = &files.InstallVault{} + } + + plain, err := data.Marshal() + if err != nil { + return nil, fmt.Errorf("failed to marshal vault: %w", err) + } + + if comments { + plain = append([]byte("# Codesphere Installer Secrets\n# Generated by OMS CLI\n\n"), plain...) + } + + return plain, nil +} + +func fileIOOrDefault(fileIO util.FileIO) util.FileIO { + if fileIO != nil { + return fileIO + } + + return util.NewFilesystemWriter() +} + +func writeVaultFile(fileIO util.FileIO, path string, data []byte) error { + if err := fileIO.MkdirAll(filepath.Dir(path), 0700); err != nil { + return fmt.Errorf("failed to create vault directory: %w", err) + } + + tmpPath, err := fileIO.CreateTemp(filepath.Dir(path), ".vault-*") + if err != nil { + return fmt.Errorf("failed to create temporary vault: %w", err) + } + defer func() { _ = fileIO.Remove(tmpPath) }() + + if err := fileIO.WriteFile(tmpPath, data, 0600); err != nil { + return fmt.Errorf("failed to write vault: %w", err) + } + + if err := fileIO.Rename(tmpPath, path); err != nil { + return fmt.Errorf("failed to replace vault: %w", err) + } + + return nil +} diff --git a/internal/installer/vault/vault_encryption.go b/internal/installer/vault/vault_encryption.go index b82be612..e5d882c4 100644 --- a/internal/installer/vault/vault_encryption.go +++ b/internal/installer/vault/vault_encryption.go @@ -4,8 +4,10 @@ package vault import ( + "errors" "fmt" "io" + "io/fs" "os" "os/exec" "path/filepath" @@ -13,40 +15,13 @@ import ( "strings" "filippo.io/age" + "github.com/codesphere-cloud/oms/internal/util" sopsage "github.com/getsops/sops/v3/age" "go.yaml.in/yaml/v3" ) 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 @@ -63,9 +38,13 @@ func (DefaultAgeKeyResolver) Resolve(explicitKeyFile, fallbackDir string) (recip // Returns the age public key (recipient) and the path to the key file (empty when // the key was supplied via SOPS_AGE_KEY). func ResolveAgeKey(explicitKeyFile, fallbackDir string) (recipient string, keyPath string, err error) { + return resolveAgeKey(util.NewFilesystemWriter(), explicitKeyFile, fallbackDir) +} + +func resolveAgeKey(fileIO util.FileIO, explicitKeyFile, fallbackDir string) (recipient string, keyPath string, err error) { // 0. Explicit key file – supplied by the caller, takes priority. if explicitKeyFile != "" { - recipient, err = readRecipientFromFile(explicitKeyFile) + recipient, err = readRecipientFromFile(fileIO, explicitKeyFile) if err != nil { return "", "", fmt.Errorf("failed to read age key from %s: %w", explicitKeyFile, err) } @@ -83,7 +62,7 @@ func ResolveAgeKey(explicitKeyFile, fallbackDir string) (recipient string, keyPa // 2. SOPS_AGE_KEY_FILE env var. if keyFile := os.Getenv(sopsage.SopsAgeKeyFileEnv); keyFile != "" { - recipient, err = readRecipientFromFile(keyFile) + recipient, err = readRecipientFromFile(fileIO, keyFile) if err != nil { return "", "", fmt.Errorf("failed to read age key from %s: %w", keyFile, err) } @@ -94,23 +73,27 @@ func ResolveAgeKey(explicitKeyFile, fallbackDir string) (recipient string, keyPa defaultPath, configErr := getUserConfigDir() if configErr == nil { defaultPath = filepath.Join(defaultPath, sopsage.SopsAgeKeyUserConfigPath) - recipient, err = readRecipientFromFile(defaultPath) + + recipient, err = readRecipientFromFile(fileIO, defaultPath) if err == nil { return recipient, defaultPath, nil } - if !os.IsNotExist(err) { + + if !errors.Is(err, fs.ErrNotExist) { return "", "", fmt.Errorf("failed to read age key from default location %s: %w", defaultPath, err) } } // 4. Generate a new key. keyPath = filepath.Join(fallbackDir, "age_key.txt") - recipient, err = readRecipientFromFile(keyPath) + + recipient, err = readRecipientFromFile(fileIO, keyPath) if err != nil { - if !os.IsNotExist(err) { + if !errors.Is(err, fs.ErrNotExist) { return "", "", fmt.Errorf("failed to read age key from fallback location %s: %w", keyPath, err) } - recipient, err = generateAgeKey(keyPath) + + recipient, err = generateAgeKey(fileIO, keyPath) if err != nil { return "", "", fmt.Errorf("failed to generate age key: %w", err) } @@ -143,15 +126,13 @@ func parseAgeRecipient(reader io.Reader) (string, error) { } // readRecipientFromFile reads an age key file and extracts the public key. -func readRecipientFromFile(path string) (recipient string, err error) { - file, err := os.Open(path) +func readRecipientFromFile(fileIO util.FileIO, path string) (string, error) { + data, err := fileIO.ReadFile(path) if err != nil { return "", err } - defer func() { - err = file.Close() - }() - return parseAgeRecipient(file) + + return parseAgeRecipient(strings.NewReader(string(data))) } func getUserConfigDir() (string, error) { @@ -165,8 +146,8 @@ func getUserConfigDir() (string, error) { // generateAgeKey generates a new age keypair and writes it to the given path. // Returns the public key (recipient). -func generateAgeKey(keyPath string) (string, error) { - if err := os.MkdirAll(filepath.Dir(keyPath), 0700); err != nil { +func generateAgeKey(fileIO util.FileIO, keyPath string) (string, error) { + if err := fileIO.MkdirAll(filepath.Dir(keyPath), 0700); err != nil { return "", fmt.Errorf("failed to create directory for age key: %w", err) } @@ -176,7 +157,7 @@ func generateAgeKey(keyPath string) (string, error) { return "", fmt.Errorf("age-keygen failed: %w: %s", err, out) } - recipient, err := readRecipientFromFile(keyPath) + recipient, err := readRecipientFromFile(fileIO, keyPath) if err != nil { return "", fmt.Errorf("failed to read generated age key: %w", err) } diff --git a/internal/installer/vault/vault_encryption_test.go b/internal/installer/vault/vault_encryption_test.go index 022e99dd..39332d4d 100644 --- a/internal/installer/vault/vault_encryption_test.go +++ b/internal/installer/vault/vault_encryption_test.go @@ -161,7 +161,7 @@ var _ = Describe("VaultEncryption", func() { }) }) - Describe("LoadVaultData", func() { + Describe("file-backed vault loading", func() { var tmpDir string BeforeEach(func() { @@ -179,11 +179,13 @@ var _ = Describe("VaultEncryption", func() { plainYAML := "secrets:\n - name: test-secret\n fields:\n password: hunter2\n" Expect(os.WriteFile(vaultPath, []byte(plainYAML), 0644)).To(Succeed()) - vault, err := vault.LoadVaultData(vaultPath, "") + backend, err := vault.New(vault.TypePlain, vault.Options{Path: vaultPath}) Expect(err).ToNot(HaveOccurred()) - Expect(vault.Secrets).To(HaveLen(1)) - Expect(vault.Secrets[0].Name).To(Equal("test-secret")) - Expect(vault.Secrets[0].Fields.Password).To(Equal("hunter2")) + loaded, err := backend.Load() + Expect(err).ToNot(HaveOccurred()) + Expect(loaded.Secrets).To(HaveLen(1)) + Expect(loaded.Secrets[0].Name).To(Equal("test-secret")) + Expect(loaded.Secrets[0].Fields.Password).To(Equal("hunter2")) }) It("unwraps a plain file with data: | wrapper (SOPS whole-file format edge case)", func() { @@ -191,11 +193,13 @@ var _ = Describe("VaultEncryption", func() { wrappedYAML := "data: |\n secrets:\n - name: test-secret\n fields:\n password: hunter2\n" Expect(os.WriteFile(vaultPath, []byte(wrappedYAML), 0644)).To(Succeed()) - vault, err := vault.LoadVaultData(vaultPath, "") + backend, err := vault.New(vault.TypePlain, vault.Options{Path: vaultPath}) + Expect(err).ToNot(HaveOccurred()) + loaded, err := backend.Load() Expect(err).ToNot(HaveOccurred()) - Expect(vault.Secrets).To(HaveLen(1)) - Expect(vault.Secrets[0].Name).To(Equal("test-secret")) - Expect(vault.Secrets[0].Fields.Password).To(Equal("hunter2")) + Expect(loaded.Secrets).To(HaveLen(1)) + Expect(loaded.Secrets[0].Name).To(Equal("test-secret")) + Expect(loaded.Secrets[0].Fields.Password).To(Equal("hunter2")) }) It("loads and decrypts a SOPS-encrypted vault end-to-end", func() { @@ -224,16 +228,19 @@ var _ = Describe("VaultEncryption", func() { encOut, err := encryptCmd.CombinedOutput() Expect(err).ToNot(HaveOccurred(), string(encOut)) - // LoadVaultData should detect SOPS, decrypt, unwrap data: |, and parse. - vault, err := vault.LoadVaultData(vaultPath, ageKeyPath) + backend, err := vault.New(vault.TypeSOPS, vault.Options{Path: vaultPath, AgeKey: ageKeyPath}) Expect(err).ToNot(HaveOccurred()) - Expect(vault.Secrets).To(HaveLen(1)) - Expect(vault.Secrets[0].Name).To(Equal("sops-secret")) - Expect(vault.Secrets[0].Fields.Password).To(Equal("s3cr3t")) + loaded, err := backend.Load() + Expect(err).ToNot(HaveOccurred()) + Expect(loaded.Secrets).To(HaveLen(1)) + Expect(loaded.Secrets[0].Name).To(Equal("sops-secret")) + Expect(loaded.Secrets[0].Fields.Password).To(Equal("s3cr3t")) }) It("returns an error for a non-existent file", func() { - _, err := vault.LoadVaultData(filepath.Join(tmpDir, "missing.yaml"), "") + backend, err := vault.New(vault.TypePlain, vault.Options{Path: filepath.Join(tmpDir, "missing.yaml")}) + Expect(err).ToNot(HaveOccurred()) + _, err = backend.Load() Expect(err).To(HaveOccurred()) }) }) diff --git a/internal/installer/vault/vault_secret_creator.go b/internal/installer/vault/vault_secret_creator.go index 67951a5a..9bdeb7f6 100644 --- a/internal/installer/vault/vault_secret_creator.go +++ b/internal/installer/vault/vault_secret_creator.go @@ -36,22 +36,28 @@ func NewVaultSecretCreator(c client.Client) *VaultSecretCreator { // - File entries produce a single key equal to the entry name. // - Field entries produce "entryName.password" and, when present, "entryName.username". func (v *VaultSecretCreator) CreateSecretFromFile(ctx context.Context, vaultFile, ageKeyPath, namespace, secretName string) error { - decrypted, err := DecryptFileWithSOPS(vaultFile, ageKeyPath) + backend, err := New(TypeSOPS, Options{Path: vaultFile, AgeKey: ageKeyPath}) if err != nil { - return fmt.Errorf("failed to decrypt vault file: %w", err) + return err } - vault := &files.InstallVault{} - if err := vault.Unmarshal(decrypted); err != nil { - return fmt.Errorf("failed to parse vault file: %w", err) + return v.CreateSecretFromStore(ctx, backend, namespace, secretName) +} + +// CreateSecretFromStore loads secrets through the abstract vault and syncs them +// to a Kubernetes secret. +func (v *VaultSecretCreator) CreateSecretFromStore(ctx context.Context, store Vault, namespace, secretName string) error { + data, err := store.Load() + if err != nil { + return fmt.Errorf("failed to load vault: %w", err) } // Always create new service accounts tokens during creation to ensure they are always valid and updated. - if err := secrets.EnsureServiceAccountTokens(vault); err != nil { + if err := secrets.EnsureServiceAccountTokens(data); err != nil { return fmt.Errorf("failed to ensure service account tokens: %w", err) } - return v.CreateSecretFromVault(ctx, vault, namespace, secretName) + return v.CreateSecretFromVault(ctx, data, namespace, secretName) } // CreateSecretFromVault creates or updates a Kubernetes secret with the contents of a Vault in the target cluster. diff --git a/internal/installer/vault/vault_store_test.go b/internal/installer/vault/vault_store_test.go new file mode 100644 index 00000000..68be7a40 --- /dev/null +++ b/internal/installer/vault/vault_store_test.go @@ -0,0 +1,97 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package vault_test + +import ( + "os" + "os/exec" + "path/filepath" + + "github.com/codesphere-cloud/oms/internal/installer/files" + "github.com/codesphere-cloud/oms/internal/installer/vault" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Vault stores", func() { + It("round-trips secrets and secret files through a plain vault", func() { + path := filepath.Join(GinkgoT().TempDir(), "prod.vault.yaml") + store, err := vault.New(vault.TypePlain, vault.Options{Path: path}) + Expect(err).NotTo(HaveOccurred()) + + want := &files.InstallVault{Secrets: []files.SecretEntry{ + {Name: "password", Fields: &files.SecretFields{Username: "user", Password: "secret"}}, + {Name: "certificate", File: &files.SecretFile{Name: "ca.pem", Content: "PEM"}}, + }} + Expect(store.Save(want)).To(Succeed()) + got, err := store.Load() + Expect(err).NotTo(HaveOccurred()) + Expect(got.GetSecret("password").Fields.Password).To(Equal("secret")) + Expect(got.GetSecret("certificate").File.Content).To(Equal("PEM")) + + info, err := os.Stat(path) + Expect(err).NotTo(HaveOccurred()) + Expect(info.Mode().Perm()).To(Equal(os.FileMode(0600))) + }) + + It("requires an age key for a SOPS vault", func() { + GinkgoT().Setenv("SOPS_AGE_KEY", "") + GinkgoT().Setenv("SOPS_AGE_KEY_FILE", "") + _, err := vault.New(vault.TypeSOPS, vault.Options{Path: filepath.Join(GinkgoT().TempDir(), "prod.vault.yaml")}) + Expect(err).To(HaveOccurred()) + }) + + It("round-trips secrets through a SOPS vault", func() { + if _, err := exec.LookPath("sops"); err != nil { + Skip("sops is not installed") + } + + if _, err := exec.LookPath("age-keygen"); err != nil { + Skip("age-keygen is not installed") + } + + dir := GinkgoT().TempDir() + keyPath := filepath.Join(dir, "age-key.txt") + output, err := exec.Command("age-keygen", "-o", keyPath).CombinedOutput() + Expect(err).NotTo(HaveOccurred(), string(output)) + + path := filepath.Join(dir, "prod.vault.yaml") + store, err := vault.New(vault.TypeSOPS, vault.Options{Path: path, AgeKey: keyPath}) + Expect(err).NotTo(HaveOccurred()) + + want := &files.InstallVault{Secrets: []files.SecretEntry{{Name: "token", Fields: &files.SecretFields{Password: "secret"}}}} + Expect(store.Save(want)).To(Succeed()) + + onDisk, err := os.ReadFile(path) + Expect(err).NotTo(HaveOccurred()) + Expect(onDisk).NotTo(BeEmpty()) + + got, err := store.Load() + Expect(err).NotTo(HaveOccurred()) + Expect(got.GetSecret("token").Fields.Password).To(Equal("secret")) + }) + + It("defaults an empty type to SOPS", func() { + vaultType, err := vault.ParseType("") + Expect(err).NotTo(HaveOccurred()) + Expect(vaultType).To(Equal(vault.TypeSOPS)) + }) + + It("validates file paths in the file-backed implementations", func() { + _, err := vault.NewPlainFileVault(vault.FileOptions{}) + Expect(err).To(HaveOccurred()) + GinkgoT().Setenv("SOPS_AGE_KEY", "test-key-is-present") + + _, err = vault.NewSOPSVault(vault.SOPSOptions{}) + Expect(err).To(HaveOccurred()) + }) + + It("handles a missing plain file inside the plain vault", func() { + store, err := vault.NewPlainFileVault(vault.FileOptions{Path: filepath.Join(GinkgoT().TempDir(), "missing.yaml")}) + Expect(err).NotTo(HaveOccurred()) + data, err := store.LoadOrCreate() + Expect(err).NotTo(HaveOccurred()) + Expect(data.Secrets).To(BeEmpty()) + }) +}) diff --git a/internal/installer/vault/vault_suite_test.go b/internal/installer/vault/vault_suite_test.go new file mode 100644 index 00000000..a36330c2 --- /dev/null +++ b/internal/installer/vault/vault_suite_test.go @@ -0,0 +1,16 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package vault_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestVault(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Vault Suite") +} diff --git a/internal/installer/vault/vault_templating_secret_store.go b/internal/installer/vault/vault_templating_secret_store.go index a886f6b8..6633300b 100644 --- a/internal/installer/vault/vault_templating_secret_store.go +++ b/internal/installer/vault/vault_templating_secret_store.go @@ -6,7 +6,6 @@ package vault import ( "errors" "fmt" - "os" "github.com/codesphere-cloud/oms/internal/installer/files" "go.yaml.in/yaml/v3" @@ -16,9 +15,8 @@ import ( // against a SOPS-encrypted install vault. The vault can either be provided // directly or loaded lazily from disk on first lookup. type VaultTemplatingSecretStore struct { - vault *files.InstallVault - vaultPath string - ageKeyPath string + vault *files.InstallVault + backend Vault } // NewVaultTemplatingSecretStore returns a store backed by an already-decrypted vault. @@ -29,16 +27,27 @@ func NewVaultTemplatingSecretStore(vault *files.InstallVault) *VaultTemplatingSe // NewLazyVaultTemplatingSecretStore returns a store that decrypts and loads the // vault from vaultPath using ageKeyPath on the first secret lookup. func NewLazyVaultTemplatingSecretStore(vaultPath, ageKeyPath string) *VaultTemplatingSecretStore { + backend := &SOPSVault{options: SOPSOptions{File: FileOptions{Path: vaultPath, FileIO: fileIOOrDefault(nil)}, AgeKey: ageKeyPath}} return &VaultTemplatingSecretStore{ - vaultPath: vaultPath, - ageKeyPath: ageKeyPath, + backend: backend, } } +// NewLazyVaultTemplatingSecretStoreWithVault returns a lazily loaded secret +// store backed by any Vault implementation. +func NewLazyVaultTemplatingSecretStoreWithVault(backend Vault) *VaultTemplatingSecretStore { + return &VaultTemplatingSecretStore{backend: backend} +} + // NewVaultTemplatingSecretStoreFromFile decrypts and loads the vault from // vaultPath using ageKeyPath and returns a store backed by it. func NewVaultTemplatingSecretStoreFromFile(vaultPath, ageKeyPath string) (*VaultTemplatingSecretStore, error) { - vault, err := LoadVaultData(vaultPath, ageKeyPath) + backend, err := New(TypeSOPS, Options{Path: vaultPath, AgeKey: ageKeyPath}) + if err != nil { + return nil, err + } + + vault, err := backend.Load() if err != nil { return nil, err } @@ -68,10 +77,12 @@ func (s *VaultTemplatingSecretStore) ensureVault() error { if s.vault != nil { return nil } - if s.vaultPath == "" { - return errors.New("vaultPath not set") + + if s.backend == nil { + return errors.New("vault backend not set") } - vault, err := LoadVaultData(s.vaultPath, s.ageKeyPath) + + vault, err := s.backend.Load() if err != nil { return err } @@ -112,62 +123,6 @@ func selectVaultSecretValue(entry files.SecretEntry, selector ...string) (string return "", fmt.Errorf("selector %q is not available on secret %q", field, entry.Name) } -// LoadVaultData reads, SOPS-decrypts, and parses the vault at vaultPath using -// the age key at ageKeyPath, returning the decoded install vault. -func LoadVaultData(vaultPath, ageKeyPath string) (*files.InstallVault, error) { - data, err := os.ReadFile(vaultPath) - if err != nil { - return nil, fmt.Errorf("failed to read vault file %s: %w", vaultPath, err) - } - - encrypted, err := isSOPSEncryptedYAML(data) - if err != nil { - return nil, fmt.Errorf("failed to inspect vault file %s: %w", vaultPath, err) - } - - if !encrypted { - return nil, fmt.Errorf("vault file %s is not SOPS-encrypted", vaultPath) - } - - decryptedData, err := DecryptFileWithSOPS(vaultPath, ageKeyPath) - if err != nil { - return nil, fmt.Errorf("failed to decrypt vault.yaml: %w", err) - } - - vault, err := parseVaultData(decryptedData) - if err != nil { - return nil, fmt.Errorf("failed to parse decrypted vault.yaml: %w", err) - } - - return vault, nil -} - -// LoadUnencryptedVaultData reads parses an unencrypted vault at vaultPath -// returning the decoded install vault. -// This is only used for GCP Bootstrapping. All other features should force a decrypted vault. -func LoadUnencryptedVaultData(vaultPath string) (*files.InstallVault, error) { - data, err := os.ReadFile(vaultPath) - if err != nil { - return nil, fmt.Errorf("failed to read vault file %s: %w", vaultPath, err) - } - - encrypted, err := isSOPSEncryptedYAML(data) - if err != nil { - return nil, fmt.Errorf("failed to inspect vault file %s: %w", vaultPath, err) - } - - if encrypted { - return nil, fmt.Errorf("failed to use unencrypted vault: vault is encrpted") - } - - vault, err := parseVaultData(data) - if err != nil { - return nil, fmt.Errorf("failed to parse decrypted vault.yaml: %w", err) - } - - return vault, nil -} - // isSOPSEncryptedYAML checks whether the YAML document contains SOPS metadata. // SOPS-encrypted YAML files have a top-level "sops" mapping that stores // encryption metadata such as age recipients, encrypted data keys, and MACs.