From 19ea435a0b9849b92c18aaecd267569805f2e1f1 Mon Sep 17 00:00:00 2001 From: Tim Schrodi Date: Wed, 27 May 2026 22:23:02 +0200 Subject: [PATCH 1/5] feat(installer): support templated install configs from vault Render install config templates through a shared configtemplating package. Add vault-backed secret lookup with explicit vault path handling for install and bootstrap flows. Signed-off-by: Tim Schrodi --- cli/cmd/config.go | 26 +++ cli/cmd/config_template.go | 87 ++++++++++ cli/cmd/config_template_test.go | 114 +++++++++++++ cli/cmd/init_install_config_test.go | 26 ++- cli/cmd/install_codesphere.go | 43 +++++ cli/cmd/install_codesphere_test.go | 4 + cli/cmd/root.go | 1 + cli/cmd/update_install_config_test.go | 37 ++++- docs/README.md | 2 +- docs/examples/install-config-templating.yaml | 22 +++ docs/oms.md | 2 +- docs/oms_beta_bootstrap-gcp.md | 1 - docs/oms_beta_bootstrap-local.md | 1 - docs/oms_config.md | 19 +++ docs/oms_config_template.md | 35 ++++ docs/oms_install_codesphere.md | 2 +- internal/bootstrap/gcp/gcp_test.go | 13 ++ internal/bootstrap/gcp/install_config.go | 16 ++ internal/bootstrap/gcp/install_config_test.go | 15 ++ internal/bootstrap/local/local.go | 16 ++ internal/configtemplating/config_template.go | 81 +++++++++ internal/installer/config.go | 9 +- internal/installer/config_manager.go | 27 +-- internal/installer/config_template_test.go | 125 ++++++++++++++ .../vault_templating_secret_store.go | 156 ++++++++++++++++++ 25 files changed, 842 insertions(+), 38 deletions(-) create mode 100644 cli/cmd/config.go create mode 100644 cli/cmd/config_template.go create mode 100644 cli/cmd/config_template_test.go create mode 100644 docs/examples/install-config-templating.yaml create mode 100644 docs/oms_config.md create mode 100644 docs/oms_config_template.md create mode 100644 internal/configtemplating/config_template.go create mode 100644 internal/installer/config_template_test.go create mode 100644 internal/installer/vault_templating_secret_store.go diff --git a/cli/cmd/config.go b/cli/cmd/config.go new file mode 100644 index 00000000..0b48bd1f --- /dev/null +++ b/cli/cmd/config.go @@ -0,0 +1,26 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "github.com/codesphere-cloud/cs-go/pkg/io" + "github.com/spf13/cobra" +) + +type ConfigCmd struct { + cmd *cobra.Command +} + +func AddConfigCmd(rootCmd *cobra.Command, opts *GlobalOptions) { + config := ConfigCmd{ + cmd: &cobra.Command{ + Use: "config", + Short: "Work with OMS configuration files", + Long: io.Long(`Work with OMS configuration files.`), + }, + } + + AddConfigTemplateCmd(config.cmd, opts) + AddCmd(rootCmd, config.cmd) +} diff --git a/cli/cmd/config_template.go b/cli/cmd/config_template.go new file mode 100644 index 00000000..19962e28 --- /dev/null +++ b/cli/cmd/config_template.go @@ -0,0 +1,87 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + "os" + + "github.com/codesphere-cloud/cs-go/pkg/io" + "github.com/codesphere-cloud/oms/internal/configtemplating" + "github.com/codesphere-cloud/oms/internal/installer" + "github.com/codesphere-cloud/oms/internal/util" + "github.com/spf13/cobra" +) + +type ConfigTemplateCmd struct { + cmd *cobra.Command + Opts ConfigTemplateOpts +} + +type ConfigTemplateOpts struct { + *GlobalOptions + Config string + Vault string + AgeKey string +} + +func (c *ConfigTemplateCmd) RunE(cmd *cobra.Command, _ []string) error { + rendered, err := c.Render() + if err != nil { + return err + } + + if _, err := fmt.Fprint(cmd.OutOrStdout(), string(rendered)); err != nil { + return fmt.Errorf("failed to write rendered config: %w", err) + } + + return nil +} + +func AddConfigTemplateCmd(parentCmd *cobra.Command, opts *GlobalOptions) { + templateCmd := &ConfigTemplateCmd{ + cmd: &cobra.Command{ + Use: "template", + Short: "Render a config.yaml template using secrets from a vault file", + Long: io.Long(`Render a config.yaml template using secrets from a prod.vault.yaml file. + +This command prints the rendered configuration to stdout so templating can be tested without running an installation.`), + Example: formatExamples("config template", []io.Example{ + { + Cmd: "--config config.yaml --vault prod.vault.yaml --age-key age_key.txt", + Desc: "Render config.yaml with secrets from prod.vault.yaml", + }, + }), + Args: cobra.ExactArgs(0), + }, + Opts: ConfigTemplateOpts{GlobalOptions: opts}, + } + + templateCmd.cmd.Flags().StringVarP(&templateCmd.Opts.Config, "config", "c", "", "Path to the config.yaml template to render (required)") + templateCmd.cmd.Flags().StringVarP(&templateCmd.Opts.Vault, "vault", "v", "", "Path to the SOPS-encrypted prod.vault.yaml file (required)") + templateCmd.cmd.Flags().StringVarP(&templateCmd.Opts.AgeKey, "age-key", "k", "", "Path to the age key file used to decrypt the vault (required)") + + util.MarkFlagRequired(templateCmd.cmd, "config") + util.MarkFlagRequired(templateCmd.cmd, "vault") + util.MarkFlagRequired(templateCmd.cmd, "age-key") + + AddCmd(parentCmd, templateCmd.cmd) + + templateCmd.cmd.RunE = templateCmd.RunE +} + +func (c *ConfigTemplateCmd) Render() ([]byte, error) { + data, err := os.ReadFile(c.Opts.Config) + if err != nil { + return nil, fmt.Errorf("failed to read config file %s: %w", c.Opts.Config, err) + } + + store := installer.NewLazyVaultTemplatingSecretStore(c.Opts.Vault, c.Opts.AgeKey) + rendered, err := configtemplating.RenderInstallConfigTemplate(data, store) + if err != nil { + return nil, fmt.Errorf("failed to render config template: %w", err) + } + + return rendered, nil +} diff --git a/cli/cmd/config_template_test.go b/cli/cmd/config_template_test.go new file mode 100644 index 00000000..8d5acedf --- /dev/null +++ b/cli/cmd/config_template_test.go @@ -0,0 +1,114 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd_test + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/codesphere-cloud/oms/cli/cmd" + "github.com/codesphere-cloud/oms/internal/installer" + "github.com/codesphere-cloud/oms/internal/installer/files" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ConfigTemplateCmd", func() { + It("renders config templates with secrets from a vault file", func() { + if !sopsAndAgeAvailable() { + Skip("sops and age-keygen not available") + } + + tempDir := GinkgoT().TempDir() + configPath := filepath.Join(tempDir, "config.yaml") + vaultPath := filepath.Join(tempDir, "prod.vault.yaml") + plaintextVaultPath := filepath.Join(tempDir, "prod.vault.plain.yaml") + ageKeyPath := filepath.Join(tempDir, "age_key.txt") + + Expect(os.WriteFile(configPath, []byte(`codesphere: + override: + global: + license: + key: '{{ secret "codesphereLicenseKey" }}' +postgres: + override: + auth: + username: '{{ secret "postgresAdmin" "fields.username" }}' + password: '{{ secret "postgresAdmin" "fields.password" }}' +`), 0644)).To(Succeed()) + Expect(exec.Command("age-keygen", "-o", ageKeyPath).Run()).To(Succeed()) + + vault := &files.InstallVault{ + Secrets: []files.SecretEntry{ + { + Name: "codesphereLicenseKey", + File: &files.SecretFile{Content: "license-secret"}, + }, + { + Name: "postgresAdmin", + Fields: &files.SecretFields{ + Username: "postgres", + Password: "admin-secret", + }, + }, + }, + } + vaultYaml, err := vault.Marshal() + Expect(err).NotTo(HaveOccurred()) + Expect(os.WriteFile(plaintextVaultPath, vaultYaml, 0600)).To(Succeed()) + recipient, err := exec.Command("age-keygen", "-y", ageKeyPath).Output() + Expect(err).NotTo(HaveOccurred()) + Expect(installer.EncryptFileWithSOPS(plaintextVaultPath, vaultPath, strings.TrimSpace(string(recipient)))).To(Succeed()) + + rootCmd := cmd.GetRootCmd() + var output bytes.Buffer + rootCmd.SetOut(&output) + rootCmd.SetErr(&output) + rootCmd.SetArgs([]string{ + "config", + "template", + "--config", + configPath, + "--vault", + vaultPath, + "--age-key", + ageKeyPath, + }) + + err = rootCmd.Execute() + + Expect(err).NotTo(HaveOccurred()) + Expect(output.String()).To(ContainSubstring("key: 'license-secret'")) + Expect(output.String()).To(ContainSubstring("username: 'postgres'")) + Expect(output.String()).To(ContainSubstring("password: 'admin-secret'")) + }) + + It("adds the template command with required flags", func() { + rootCmd := cmd.GetRootCmd() + + configCmd, _, err := rootCmd.Find([]string{"config", "template"}) + Expect(err).NotTo(HaveOccurred()) + Expect(configCmd).NotTo(BeNil()) + Expect(configCmd.Use).To(Equal("template")) + Expect(configCmd.Short).To(Equal("Render a config.yaml template using secrets from a vault file")) + + Expect(configCmd.Flags().Lookup("config")).NotTo(BeNil()) + Expect(configCmd.Flags().Lookup("vault")).NotTo(BeNil()) + Expect(configCmd.Flags().Lookup("age-key")).NotTo(BeNil()) + }) +}) + +func sopsAndAgeAvailable() bool { + if _, err := exec.LookPath("sops"); err != nil { + return false + } + if _, err := exec.LookPath("age-keygen"); err != nil { + return false + } + return true +} diff --git a/cli/cmd/init_install_config_test.go b/cli/cmd/init_install_config_test.go index 21bdad4a..a7a2545d 100644 --- a/cli/cmd/init_install_config_test.go +++ b/cli/cmd/init_install_config_test.go @@ -5,6 +5,9 @@ package cmd import ( "os" + "os/exec" + "path/filepath" + "strings" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -162,15 +165,34 @@ codesphere: Context("valid configuration", func() { It("validates successfully", func() { + if !sopsAndAgeAvailableForUpdateInstallConfig() { + Skip("sops and age-keygen not available") + } + _, err := configFile.WriteString(validConfig) Expect(err).NotTo(HaveOccurred()) err = configFile.Close() Expect(err).NotTo(HaveOccurred()) - _, err = vaultFile.WriteString(validVault) - Expect(err).NotTo(HaveOccurred()) err = vaultFile.Close() Expect(err).NotTo(HaveOccurred()) + tempDir := GinkgoT().TempDir() + ageKeyPath := filepath.Join(tempDir, "age_key.txt") + plaintextVaultPath := filepath.Join(tempDir, "prod.vault.plain.yaml") + Expect(os.WriteFile(plaintextVaultPath, []byte(validVault), 0600)).To(Succeed()) + Expect(exec.Command("age-keygen", "-o", ageKeyPath).Run()).To(Succeed()) + recipient, err := exec.Command("age-keygen", "-y", ageKeyPath).Output() + Expect(err).NotTo(HaveOccurred()) + Expect(installer.EncryptFileWithSOPS(plaintextVaultPath, vaultFile.Name(), strings.TrimSpace(string(recipient)))).To(Succeed()) + previousAgeKeyFile, hadPreviousAgeKeyFile := os.LookupEnv("SOPS_AGE_KEY_FILE") + Expect(os.Setenv("SOPS_AGE_KEY_FILE", ageKeyPath)).To(Succeed()) + DeferCleanup(func() { + if hadPreviousAgeKeyFile { + Expect(os.Setenv("SOPS_AGE_KEY_FILE", previousAgeKeyFile)).To(Succeed()) + return + } + Expect(os.Unsetenv("SOPS_AGE_KEY_FILE")).To(Succeed()) + }) c := &InitInstallConfigCmd{ Opts: &InitInstallConfigOpts{ diff --git a/cli/cmd/install_codesphere.go b/cli/cmd/install_codesphere.go index 036a7dd1..4b076c0d 100644 --- a/cli/cmd/install_codesphere.go +++ b/cli/cmd/install_codesphere.go @@ -16,8 +16,10 @@ import ( "strings" "github.com/codesphere-cloud/cs-go/pkg/io" + "github.com/codesphere-cloud/oms/internal/configtemplating" "github.com/codesphere-cloud/oms/internal/env" "github.com/codesphere-cloud/oms/internal/installer" + "github.com/codesphere-cloud/oms/internal/installer/files" "github.com/codesphere-cloud/oms/internal/system" "github.com/codesphere-cloud/oms/internal/util" "github.com/spf13/cobra" @@ -35,6 +37,7 @@ type InstallCodesphereOpts struct { Package string Force bool Config string + Vault string PrivKey string SkipSteps []string CodesphereOnly bool @@ -79,6 +82,7 @@ func AddInstallCodesphereCmd(install *cobra.Command, opts *GlobalOptions) { codesphere.cmd.Flags().StringVarP(&codesphere.Opts.Package, "package", "p", "", "Package file (e.g. codesphere-v1.2.3-installer.tar.gz) to load binaries, installer etc. from") codesphere.cmd.Flags().BoolVarP(&codesphere.Opts.Force, "force", "f", false, "Enforce package extraction") codesphere.cmd.Flags().StringVarP(&codesphere.Opts.Config, "config", "c", "", "Path to the Codesphere Private Cloud configuration file (yaml)") + codesphere.cmd.Flags().StringVar(&codesphere.Opts.Vault, "vault", "prod.vault.yaml", "Path to the SOPS-encrypted prod.vault.yaml file used for config templating") codesphere.cmd.Flags().StringVarP(&codesphere.Opts.PrivKey, "priv-key", "k", "", "Path to the private key to encrypt/decrypt secrets") codesphere.cmd.Flags().StringSliceVarP(&codesphere.Opts.SkipSteps, "skip-steps", "s", []string{}, "Steps to be skipped. E.g. copy-dependencies, extract-dependencies, load-container-images, ceph, kubernetes") codesphere.cmd.Flags().BoolVar(&codesphere.Opts.CodesphereOnly, "codesphere-only", false, "Install only Codesphere without dependencies") @@ -98,10 +102,27 @@ func (c *InstallCodesphereCmd) ExtractAndInstall(pm installer.PackageManager, cm return fmt.Errorf("codesphere installation is only supported on Linux amd64. Current platform: %s/%s", goos, goarch) } + originalConfig := c.Opts.Config + cleanup := func() {} + if c.Opts.Vault != "" { + store := installer.NewLazyVaultTemplatingSecretStore(c.Opts.Vault, c.Opts.PrivKey) + renderedConfig, renderCleanup, err := configtemplating.RenderConfigFileToTempIfNeeded(c.Opts.Config, store) + if err != nil { + return fmt.Errorf("failed to render config template: %w", err) + } + cleanup = renderCleanup + c.Opts.Config = renderedConfig + } + defer cleanup() + defer func() { + c.Opts.Config = originalConfig + }() + config, err := cm.ParseConfigYaml(c.Opts.Config) if err != nil { return fmt.Errorf("failed to extract config.yaml: %w", err) } + c.warnIfVaultDirDiffersFromSecretsDir(config) err = pm.Extract(c.Opts.Force) if err != nil { @@ -246,6 +267,28 @@ func (c *InstallCodesphereCmd) ExtractAndInstall(pm installer.PackageManager, cm return nil } +func (c *InstallCodesphereCmd) warnIfVaultDirDiffersFromSecretsDir(config files.RootConfig) { + if c.Opts.Vault == "" || config.Secrets.BaseDir == "" { + return + } + + vaultDir, err := filepath.Abs(filepath.Dir(c.Opts.Vault)) + if err != nil { + log.Printf("Warning: failed to resolve vault directory for %s: %v", c.Opts.Vault, err) + return + } + + secretsDir, err := filepath.Abs(config.Secrets.BaseDir) + if err != nil { + log.Printf("Warning: failed to resolve configured secrets baseDir %s: %v", config.Secrets.BaseDir, err) + return + } + + if vaultDir != secretsDir { + log.Printf("Warning: config secrets.baseDir (%s) does not match the directory of --vault (%s)", secretsDir, vaultDir) + } +} + func (c *InstallCodesphereCmd) ListPackageContents(pm installer.PackageManager) ([]string, error) { packageDir := pm.GetWorkDir() if !pm.FileIO().Exists(packageDir) { diff --git a/cli/cmd/install_codesphere_test.go b/cli/cmd/install_codesphere_test.go index 23457d3e..868d3cec 100644 --- a/cli/cmd/install_codesphere_test.go +++ b/cli/cmd/install_codesphere_test.go @@ -510,6 +510,10 @@ var _ = Describe("AddInstallCodesphereCmd", func() { Expect(privKeyFlag).NotTo(BeNil()) Expect(privKeyFlag.Shorthand).To(Equal("k")) + vaultFlag := codesphereCmd.Flags().Lookup("vault") + Expect(vaultFlag).NotTo(BeNil()) + Expect(vaultFlag.DefValue).To(Equal("prod.vault.yaml")) + skipStepFlag := codesphereCmd.Flags().Lookup("skip-steps") Expect(skipStepFlag).NotTo(BeNil()) Expect(skipStepFlag.Shorthand).To(Equal("s")) diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 602a9773..416f50ec 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -74,6 +74,7 @@ func GetRootCmd() *cobra.Command { AddDownloadCmd(rootCmd, opts) AddInstallCmd(rootCmd, opts) AddInitCmd(rootCmd, opts) + AddConfigCmd(rootCmd, opts) AddBuildCmd(rootCmd, opts) AddLicensesCmd(rootCmd) diff --git a/cli/cmd/update_install_config_test.go b/cli/cmd/update_install_config_test.go index ac1f1ae4..764bc865 100644 --- a/cli/cmd/update_install_config_test.go +++ b/cli/cmd/update_install_config_test.go @@ -6,6 +6,8 @@ package cmd import ( "fmt" "os" + "os/exec" + "path/filepath" "strings" . "github.com/onsi/ginkgo/v2" @@ -36,6 +38,10 @@ var _ = Describe("UpdateInstallConfig", func() { ) BeforeEach(func() { + if !sopsAndAgeAvailableForUpdateInstallConfig() { + Skip("sops and age-keygen not available") + } + var err error configFile, err = os.CreateTemp("", "config-*.yaml") Expect(err).NotTo(HaveOccurred()) @@ -177,8 +183,23 @@ codesphere: err = os.WriteFile(configFile.Name(), []byte(initialConfig), 0644) Expect(err).NotTo(HaveOccurred()) - err = os.WriteFile(vaultFile.Name(), []byte(initialVault), 0644) + ageKeyPath := filepath.Join(GinkgoT().TempDir(), "age_key.txt") + plaintextVaultPath := filepath.Join(filepath.Dir(ageKeyPath), "prod.vault.plain.yaml") + err = os.WriteFile(plaintextVaultPath, []byte(initialVault), 0600) Expect(err).NotTo(HaveOccurred()) + Expect(exec.Command("age-keygen", "-o", ageKeyPath).Run()).To(Succeed()) + recipient, err := exec.Command("age-keygen", "-y", ageKeyPath).Output() + Expect(err).NotTo(HaveOccurred()) + Expect(installer.EncryptFileWithSOPS(plaintextVaultPath, vaultFile.Name(), strings.TrimSpace(string(recipient)))).To(Succeed()) + previousAgeKeyFile, hadPreviousAgeKeyFile := os.LookupEnv("SOPS_AGE_KEY_FILE") + Expect(os.Setenv("SOPS_AGE_KEY_FILE", ageKeyPath)).To(Succeed()) + DeferCleanup(func() { + if hadPreviousAgeKeyFile { + Expect(os.Setenv("SOPS_AGE_KEY_FILE", previousAgeKeyFile)).To(Succeed()) + return + } + Expect(os.Unsetenv("SOPS_AGE_KEY_FILE")).To(Succeed()) + }) opts = &UpdateInstallConfigOpts{ GlobalOptions: &GlobalOptions{}, @@ -349,9 +370,7 @@ codesphere: ) BeforeEach(func() { - var err error - initialVaultContent, err = os.ReadFile(vaultFile.Name()) - Expect(err).NotTo(HaveOccurred()) + initialVaultContent = []byte(initialVault) }) It("should preserve all vault entries during non-certificate update", func() { @@ -433,6 +452,16 @@ codesphere: }) }) +func sopsAndAgeAvailableForUpdateInstallConfig() bool { + if _, err := exec.LookPath("sops"); err != nil { + return false + } + if _, err := exec.LookPath("age-keygen"); err != nil { + return false + } + return true +} + var _ = Describe("SecretDependencyTracker", func() { var tracker *SecretDependencyTracker diff --git a/docs/README.md b/docs/README.md index c3a10ccc..4ee1bc56 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,6 +19,7 @@ like downloading new versions. * [oms beta](oms_beta.md) - Commands for early testing * [oms build](oms_build.md) - Build and push images to a registry +* [oms config](oms_config.md) - Work with OMS configuration files * [oms create](oms_create.md) - Create resources for Codesphere * [oms download](oms_download.md) - Download resources available through OMS * [oms init](oms_init.md) - Initialize configuration files @@ -30,4 +31,3 @@ like downloading new versions. * [oms smoketest](oms_smoketest.md) - Run smoke tests for Codesphere components * [oms update](oms_update.md) - Update OMS related resources * [oms version](oms_version.md) - Print version - diff --git a/docs/examples/install-config-templating.yaml b/docs/examples/install-config-templating.yaml new file mode 100644 index 00000000..b86b564a --- /dev/null +++ b/docs/examples/install-config-templating.yaml @@ -0,0 +1,22 @@ +# Example config.yaml fragment using secrets from prod.vault.yaml. +# +# Rendered by: +# oms install codesphere --config config.yaml --vault prod.vault.yaml --priv-key age_key.txt ... +# +# Secret values are read dynamically from prod.vault.yaml and are not stored in +# plaintext in config.yaml. + +secrets: + baseDir: ./secrets + +codesphere: + override: + global: + license: + key: '{{ secret "codesphereLicenseKey" }}' + +postgres: + override: + auth: + username: '{{ secret "postgresAdmin" "fields.username" }}' + password: '{{ secret "postgresAdmin" "fields.password" }}' diff --git a/docs/oms.md b/docs/oms.md index c3a10ccc..4ee1bc56 100644 --- a/docs/oms.md +++ b/docs/oms.md @@ -19,6 +19,7 @@ like downloading new versions. * [oms beta](oms_beta.md) - Commands for early testing * [oms build](oms_build.md) - Build and push images to a registry +* [oms config](oms_config.md) - Work with OMS configuration files * [oms create](oms_create.md) - Create resources for Codesphere * [oms download](oms_download.md) - Download resources available through OMS * [oms init](oms_init.md) - Initialize configuration files @@ -30,4 +31,3 @@ like downloading new versions. * [oms smoketest](oms_smoketest.md) - Run smoke tests for Codesphere components * [oms update](oms_update.md) - Update OMS related resources * [oms version](oms_version.md) - Print version - diff --git a/docs/oms_beta_bootstrap-gcp.md b/docs/oms_beta_bootstrap-gcp.md index a2e63899..18538f90 100644 --- a/docs/oms_beta_bootstrap-gcp.md +++ b/docs/oms_beta_bootstrap-gcp.md @@ -87,4 +87,3 @@ oms beta bootstrap-gcp [flags] * [oms beta bootstrap-gcp cleanup](oms_beta_bootstrap-gcp_cleanup.md) - Clean up GCP infrastructure created by bootstrap-gcp * [oms beta bootstrap-gcp postconfig](oms_beta_bootstrap-gcp_postconfig.md) - Run post-configuration steps for GCP bootstrapping * [oms beta bootstrap-gcp restart-vms](oms_beta_bootstrap-gcp_restart-vms.md) - Restart stopped or terminated GCP VMs - diff --git a/docs/oms_beta_bootstrap-local.md b/docs/oms_beta_bootstrap-local.md index 9703d33a..e3e393d2 100644 --- a/docs/oms_beta_bootstrap-local.md +++ b/docs/oms_beta_bootstrap-local.md @@ -37,4 +37,3 @@ oms beta bootstrap-local [flags] ### SEE ALSO * [oms beta](oms_beta.md) - Commands for early testing - diff --git a/docs/oms_config.md b/docs/oms_config.md new file mode 100644 index 00000000..e41a0170 --- /dev/null +++ b/docs/oms_config.md @@ -0,0 +1,19 @@ +## oms config + +Work with OMS configuration files + +### Synopsis + +Work with OMS configuration files. + +### Options + +``` + -h, --help help for config +``` + +### SEE ALSO + +* [oms](oms.md) - Codesphere Operations Management System (OMS) +* [oms config template](oms_config_template.md) - Render a config.yaml template using secrets from a vault file + diff --git a/docs/oms_config_template.md b/docs/oms_config_template.md new file mode 100644 index 00000000..696f7f32 --- /dev/null +++ b/docs/oms_config_template.md @@ -0,0 +1,35 @@ +## oms config template + +Render a config.yaml template using secrets from a vault file + +### Synopsis + +Render a config.yaml template using secrets from a prod.vault.yaml file. + +This command prints the rendered configuration to stdout so templating can be tested without running an installation. + +``` +oms config template [flags] +``` + +### Examples + +``` +# Render config.yaml with secrets from prod.vault.yaml +$ oms config template --config config.yaml --vault prod.vault.yaml --age-key age_key.txt + +``` + +### Options + +``` + -k, --age-key string Path to the age key file used to decrypt the vault (required) + -c, --config string Path to the config.yaml template to render (required) + -h, --help help for template + -v, --vault string Path to the SOPS-encrypted prod.vault.yaml file (required) +``` + +### SEE ALSO + +* [oms config](oms_config.md) - Work with OMS configuration files + diff --git a/docs/oms_install_codesphere.md b/docs/oms_install_codesphere.md index ead037d2..8d141d58 100644 --- a/docs/oms_install_codesphere.md +++ b/docs/oms_install_codesphere.md @@ -33,9 +33,9 @@ $ oms install codesphere -p codesphere-v1.2.3-installer-lite.tar.gz -k 0 { + field = selector[0] + } + + switch field { + case "", "content", "file.content": + if entry.File != nil { + return entry.File.Content, nil + } + if entry.Fields != nil { + return entry.Fields.Password, nil + } + case "name", "file.name": + if entry.File != nil { + return entry.File.Name, nil + } + case "password", "fields.password": + if entry.Fields != nil { + return entry.Fields.Password, nil + } + case "username", "fields.username": + if entry.Fields != nil { + return entry.Fields.Username, nil + } + default: + return "", fmt.Errorf("unsupported selector %q for secret %q", field, entry.Name) + } + + return "", fmt.Errorf("selector %q is not available on secret %q", field, entry.Name) +} + +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) + } + + decrypted, err := DecryptFileWithSOPS(vaultPath, ageKeyPath) + if err != nil { + return nil, fmt.Errorf("failed to decrypt vault.yaml: %w", err) + } + + vault, err := parseVaultData(decrypted) + 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. +func isSOPSEncryptedYAML(data []byte) (bool, error) { + var doc yaml.Node + if err := yaml.Unmarshal(data, &doc); err != nil { + return false, err + } + if len(doc.Content) == 0 { + return false, nil + } + + root := doc.Content[0] + if root.Kind != yaml.MappingNode { + return false, nil + } + + for i := 0; i+1 < len(root.Content); i += 2 { + if root.Content[i].Value == "sops" && root.Content[i+1].Kind == yaml.MappingNode { + return true, nil + } + } + + return false, nil +} + +func parseVaultData(data []byte) (*files.InstallVault, error) { + vault := &files.InstallVault{} + if err := vault.Unmarshal(data); err != nil { + return nil, err + } + return vault, nil +} From 8e4055ffd40e71def12b14cd01bfb29d38c14520 Mon Sep 17 00:00:00 2001 From: schrodit <7979201+schrodit@users.noreply.github.com> Date: Thu, 28 May 2026 06:47:48 +0000 Subject: [PATCH 2/5] chore(docs): Auto-update docs and licenses Signed-off-by: schrodit <7979201+schrodit@users.noreply.github.com> --- docs/README.md | 1 + docs/examples/install-config-templating.yaml | 22 -------------------- docs/oms.md | 1 + docs/oms_beta_bootstrap-gcp.md | 1 + docs/oms_beta_bootstrap-local.md | 1 + docs/oms_install_codesphere.md | 1 + 6 files changed, 5 insertions(+), 22 deletions(-) delete mode 100644 docs/examples/install-config-templating.yaml diff --git a/docs/README.md b/docs/README.md index 4ee1bc56..787b54ff 100644 --- a/docs/README.md +++ b/docs/README.md @@ -31,3 +31,4 @@ like downloading new versions. * [oms smoketest](oms_smoketest.md) - Run smoke tests for Codesphere components * [oms update](oms_update.md) - Update OMS related resources * [oms version](oms_version.md) - Print version + diff --git a/docs/examples/install-config-templating.yaml b/docs/examples/install-config-templating.yaml deleted file mode 100644 index b86b564a..00000000 --- a/docs/examples/install-config-templating.yaml +++ /dev/null @@ -1,22 +0,0 @@ -# Example config.yaml fragment using secrets from prod.vault.yaml. -# -# Rendered by: -# oms install codesphere --config config.yaml --vault prod.vault.yaml --priv-key age_key.txt ... -# -# Secret values are read dynamically from prod.vault.yaml and are not stored in -# plaintext in config.yaml. - -secrets: - baseDir: ./secrets - -codesphere: - override: - global: - license: - key: '{{ secret "codesphereLicenseKey" }}' - -postgres: - override: - auth: - username: '{{ secret "postgresAdmin" "fields.username" }}' - password: '{{ secret "postgresAdmin" "fields.password" }}' diff --git a/docs/oms.md b/docs/oms.md index 4ee1bc56..787b54ff 100644 --- a/docs/oms.md +++ b/docs/oms.md @@ -31,3 +31,4 @@ like downloading new versions. * [oms smoketest](oms_smoketest.md) - Run smoke tests for Codesphere components * [oms update](oms_update.md) - Update OMS related resources * [oms version](oms_version.md) - Print version + diff --git a/docs/oms_beta_bootstrap-gcp.md b/docs/oms_beta_bootstrap-gcp.md index 18538f90..a2e63899 100644 --- a/docs/oms_beta_bootstrap-gcp.md +++ b/docs/oms_beta_bootstrap-gcp.md @@ -87,3 +87,4 @@ oms beta bootstrap-gcp [flags] * [oms beta bootstrap-gcp cleanup](oms_beta_bootstrap-gcp_cleanup.md) - Clean up GCP infrastructure created by bootstrap-gcp * [oms beta bootstrap-gcp postconfig](oms_beta_bootstrap-gcp_postconfig.md) - Run post-configuration steps for GCP bootstrapping * [oms beta bootstrap-gcp restart-vms](oms_beta_bootstrap-gcp_restart-vms.md) - Restart stopped or terminated GCP VMs + diff --git a/docs/oms_beta_bootstrap-local.md b/docs/oms_beta_bootstrap-local.md index e3e393d2..9703d33a 100644 --- a/docs/oms_beta_bootstrap-local.md +++ b/docs/oms_beta_bootstrap-local.md @@ -37,3 +37,4 @@ oms beta bootstrap-local [flags] ### SEE ALSO * [oms beta](oms_beta.md) - Commands for early testing + diff --git a/docs/oms_install_codesphere.md b/docs/oms_install_codesphere.md index 8d141d58..23f6dd86 100644 --- a/docs/oms_install_codesphere.md +++ b/docs/oms_install_codesphere.md @@ -39,3 +39,4 @@ $ oms install codesphere -p codesphere-v1.2.3-installer-lite.tar.gz -k Date: Mon, 1 Jun 2026 17:55:45 +0200 Subject: [PATCH 3/5] refactor(installer): address review feedback on config templating - rename `oms config template` -> `oms template config` for verb-first consistency with other commands - rename RenderConfigFileToTempIfNeeded -> RenderConfigFileToTemp (always creates a temp file) - extract lazy vault loading into ensureVault() with clearer error messages and drop the redundant nil-receiver check - add doc comments to exported templating/secret-store APIs - warn that `template config` prints secrets to stdout - tidy config template test payload and regenerate docs Signed-off-by: Tim Schrodi --- cli/cmd/config.go | 26 ----------- cli/cmd/install_codesphere.go | 2 +- cli/cmd/root.go | 2 +- cli/cmd/template.go | 26 +++++++++++ ...{config_template.go => template_config.go} | 40 ++++++++-------- ...mplate_test.go => template_config_test.go} | 10 ++-- docs/README.md | 2 +- docs/oms.md | 2 +- docs/oms_config.md | 19 -------- docs/oms_template.md | 19 ++++++++ ...fig_template.md => oms_template_config.md} | 10 ++-- internal/configtemplating/config_template.go | 7 ++- internal/installer/config_template_test.go | 10 ++-- .../vault_templating_secret_store.go | 46 ++++++++++++++----- 14 files changed, 126 insertions(+), 95 deletions(-) delete mode 100644 cli/cmd/config.go create mode 100644 cli/cmd/template.go rename cli/cmd/{config_template.go => template_config.go} (55%) rename cli/cmd/{config_template_test.go => template_config_test.go} (93%) delete mode 100644 docs/oms_config.md create mode 100644 docs/oms_template.md rename docs/{oms_config_template.md => oms_template_config.md} (74%) diff --git a/cli/cmd/config.go b/cli/cmd/config.go deleted file mode 100644 index 0b48bd1f..00000000 --- a/cli/cmd/config.go +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) Codesphere Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "github.com/codesphere-cloud/cs-go/pkg/io" - "github.com/spf13/cobra" -) - -type ConfigCmd struct { - cmd *cobra.Command -} - -func AddConfigCmd(rootCmd *cobra.Command, opts *GlobalOptions) { - config := ConfigCmd{ - cmd: &cobra.Command{ - Use: "config", - Short: "Work with OMS configuration files", - Long: io.Long(`Work with OMS configuration files.`), - }, - } - - AddConfigTemplateCmd(config.cmd, opts) - AddCmd(rootCmd, config.cmd) -} diff --git a/cli/cmd/install_codesphere.go b/cli/cmd/install_codesphere.go index 4b076c0d..bdb2f896 100644 --- a/cli/cmd/install_codesphere.go +++ b/cli/cmd/install_codesphere.go @@ -106,7 +106,7 @@ func (c *InstallCodesphereCmd) ExtractAndInstall(pm installer.PackageManager, cm cleanup := func() {} if c.Opts.Vault != "" { store := installer.NewLazyVaultTemplatingSecretStore(c.Opts.Vault, c.Opts.PrivKey) - renderedConfig, renderCleanup, err := configtemplating.RenderConfigFileToTempIfNeeded(c.Opts.Config, store) + renderedConfig, renderCleanup, err := configtemplating.RenderConfigFileToTemp(c.Opts.Config, store) if err != nil { return fmt.Errorf("failed to render config template: %w", err) } diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 416f50ec..f54f7644 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -74,7 +74,7 @@ func GetRootCmd() *cobra.Command { AddDownloadCmd(rootCmd, opts) AddInstallCmd(rootCmd, opts) AddInitCmd(rootCmd, opts) - AddConfigCmd(rootCmd, opts) + AddTemplateCmd(rootCmd, opts) AddBuildCmd(rootCmd, opts) AddLicensesCmd(rootCmd) diff --git a/cli/cmd/template.go b/cli/cmd/template.go new file mode 100644 index 00000000..fc78049d --- /dev/null +++ b/cli/cmd/template.go @@ -0,0 +1,26 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "github.com/codesphere-cloud/cs-go/pkg/io" + "github.com/spf13/cobra" +) + +type TemplateCmd struct { + cmd *cobra.Command +} + +func AddTemplateCmd(rootCmd *cobra.Command, opts *GlobalOptions) { + template := TemplateCmd{ + cmd: &cobra.Command{ + Use: "template", + Short: "Render OMS configuration templates", + Long: io.Long(`Render OMS configuration templates.`), + }, + } + + AddTemplateConfigCmd(template.cmd, opts) + AddCmd(rootCmd, template.cmd) +} diff --git a/cli/cmd/config_template.go b/cli/cmd/template_config.go similarity index 55% rename from cli/cmd/config_template.go rename to cli/cmd/template_config.go index 19962e28..81bb6e97 100644 --- a/cli/cmd/config_template.go +++ b/cli/cmd/template_config.go @@ -14,24 +14,28 @@ import ( "github.com/spf13/cobra" ) -type ConfigTemplateCmd struct { +type TemplateConfigCmd struct { cmd *cobra.Command - Opts ConfigTemplateOpts + Opts TemplateConfigOpts } -type ConfigTemplateOpts struct { +type TemplateConfigOpts struct { *GlobalOptions Config string Vault string AgeKey string } -func (c *ConfigTemplateCmd) RunE(cmd *cobra.Command, _ []string) error { +func (c *TemplateConfigCmd) RunE(cmd *cobra.Command, _ []string) error { rendered, err := c.Render() if err != nil { return err } + if _, err := fmt.Fprintln(cmd.ErrOrStderr(), "Warning: the rendered config is printed to stdout in plaintext and may contain secrets from the vault."); err != nil { + return fmt.Errorf("failed to write warning message: %w", err) + } + if _, err := fmt.Fprint(cmd.OutOrStdout(), string(rendered)); err != nil { return fmt.Errorf("failed to write rendered config: %w", err) } @@ -39,15 +43,15 @@ func (c *ConfigTemplateCmd) RunE(cmd *cobra.Command, _ []string) error { return nil } -func AddConfigTemplateCmd(parentCmd *cobra.Command, opts *GlobalOptions) { - templateCmd := &ConfigTemplateCmd{ +func AddTemplateConfigCmd(parentCmd *cobra.Command, opts *GlobalOptions) { + configCmd := &TemplateConfigCmd{ cmd: &cobra.Command{ - Use: "template", + Use: "config", Short: "Render a config.yaml template using secrets from a vault file", Long: io.Long(`Render a config.yaml template using secrets from a prod.vault.yaml file. This command prints the rendered configuration to stdout so templating can be tested without running an installation.`), - Example: formatExamples("config template", []io.Example{ + Example: formatExamples("template config", []io.Example{ { Cmd: "--config config.yaml --vault prod.vault.yaml --age-key age_key.txt", Desc: "Render config.yaml with secrets from prod.vault.yaml", @@ -55,23 +59,23 @@ This command prints the rendered configuration to stdout so templating can be te }), Args: cobra.ExactArgs(0), }, - Opts: ConfigTemplateOpts{GlobalOptions: opts}, + Opts: TemplateConfigOpts{GlobalOptions: opts}, } - templateCmd.cmd.Flags().StringVarP(&templateCmd.Opts.Config, "config", "c", "", "Path to the config.yaml template to render (required)") - templateCmd.cmd.Flags().StringVarP(&templateCmd.Opts.Vault, "vault", "v", "", "Path to the SOPS-encrypted prod.vault.yaml file (required)") - templateCmd.cmd.Flags().StringVarP(&templateCmd.Opts.AgeKey, "age-key", "k", "", "Path to the age key file used to decrypt the vault (required)") + 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)") - util.MarkFlagRequired(templateCmd.cmd, "config") - util.MarkFlagRequired(templateCmd.cmd, "vault") - util.MarkFlagRequired(templateCmd.cmd, "age-key") + util.MarkFlagRequired(configCmd.cmd, "config") + util.MarkFlagRequired(configCmd.cmd, "vault") + util.MarkFlagRequired(configCmd.cmd, "age-key") - AddCmd(parentCmd, templateCmd.cmd) + AddCmd(parentCmd, configCmd.cmd) - templateCmd.cmd.RunE = templateCmd.RunE + configCmd.cmd.RunE = configCmd.RunE } -func (c *ConfigTemplateCmd) Render() ([]byte, error) { +func (c *TemplateConfigCmd) Render() ([]byte, error) { data, err := os.ReadFile(c.Opts.Config) if err != nil { return nil, fmt.Errorf("failed to read config file %s: %w", c.Opts.Config, err) diff --git a/cli/cmd/config_template_test.go b/cli/cmd/template_config_test.go similarity index 93% rename from cli/cmd/config_template_test.go rename to cli/cmd/template_config_test.go index 8d5acedf..c107f20d 100644 --- a/cli/cmd/config_template_test.go +++ b/cli/cmd/template_config_test.go @@ -18,7 +18,7 @@ import ( . "github.com/onsi/gomega" ) -var _ = Describe("ConfigTemplateCmd", func() { +var _ = Describe("TemplateConfigCmd", func() { It("renders config templates with secrets from a vault file", func() { if !sopsAndAgeAvailable() { Skip("sops and age-keygen not available") @@ -70,8 +70,8 @@ postgres: rootCmd.SetOut(&output) rootCmd.SetErr(&output) rootCmd.SetArgs([]string{ - "config", "template", + "config", "--config", configPath, "--vault", @@ -88,13 +88,13 @@ postgres: Expect(output.String()).To(ContainSubstring("password: 'admin-secret'")) }) - It("adds the template command with required flags", func() { + It("adds the config command with required flags", func() { rootCmd := cmd.GetRootCmd() - configCmd, _, err := rootCmd.Find([]string{"config", "template"}) + configCmd, _, err := rootCmd.Find([]string{"template", "config"}) Expect(err).NotTo(HaveOccurred()) Expect(configCmd).NotTo(BeNil()) - Expect(configCmd.Use).To(Equal("template")) + Expect(configCmd.Use).To(Equal("config")) Expect(configCmd.Short).To(Equal("Render a config.yaml template using secrets from a vault file")) Expect(configCmd.Flags().Lookup("config")).NotTo(BeNil()) diff --git a/docs/README.md b/docs/README.md index 787b54ff..b17210fa 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,7 +19,6 @@ like downloading new versions. * [oms beta](oms_beta.md) - Commands for early testing * [oms build](oms_build.md) - Build and push images to a registry -* [oms config](oms_config.md) - Work with OMS configuration files * [oms create](oms_create.md) - Create resources for Codesphere * [oms download](oms_download.md) - Download resources available through OMS * [oms init](oms_init.md) - Initialize configuration files @@ -29,6 +28,7 @@ like downloading new versions. * [oms register](oms_register.md) - Register a new API key * [oms revoke](oms_revoke.md) - Revoke resources available through OMS * [oms smoketest](oms_smoketest.md) - Run smoke tests for Codesphere components +* [oms template](oms_template.md) - Render OMS configuration templates * [oms update](oms_update.md) - Update OMS related resources * [oms version](oms_version.md) - Print version diff --git a/docs/oms.md b/docs/oms.md index 787b54ff..b17210fa 100644 --- a/docs/oms.md +++ b/docs/oms.md @@ -19,7 +19,6 @@ like downloading new versions. * [oms beta](oms_beta.md) - Commands for early testing * [oms build](oms_build.md) - Build and push images to a registry -* [oms config](oms_config.md) - Work with OMS configuration files * [oms create](oms_create.md) - Create resources for Codesphere * [oms download](oms_download.md) - Download resources available through OMS * [oms init](oms_init.md) - Initialize configuration files @@ -29,6 +28,7 @@ like downloading new versions. * [oms register](oms_register.md) - Register a new API key * [oms revoke](oms_revoke.md) - Revoke resources available through OMS * [oms smoketest](oms_smoketest.md) - Run smoke tests for Codesphere components +* [oms template](oms_template.md) - Render OMS configuration templates * [oms update](oms_update.md) - Update OMS related resources * [oms version](oms_version.md) - Print version diff --git a/docs/oms_config.md b/docs/oms_config.md deleted file mode 100644 index e41a0170..00000000 --- a/docs/oms_config.md +++ /dev/null @@ -1,19 +0,0 @@ -## oms config - -Work with OMS configuration files - -### Synopsis - -Work with OMS configuration files. - -### Options - -``` - -h, --help help for config -``` - -### SEE ALSO - -* [oms](oms.md) - Codesphere Operations Management System (OMS) -* [oms config template](oms_config_template.md) - Render a config.yaml template using secrets from a vault file - diff --git a/docs/oms_template.md b/docs/oms_template.md new file mode 100644 index 00000000..f3bc22c2 --- /dev/null +++ b/docs/oms_template.md @@ -0,0 +1,19 @@ +## oms template + +Render OMS configuration templates + +### Synopsis + +Render OMS configuration templates. + +### Options + +``` + -h, --help help for template +``` + +### SEE ALSO + +* [oms](oms.md) - Codesphere Operations Management System (OMS) +* [oms template config](oms_template_config.md) - Render a config.yaml template using secrets from a vault file + diff --git a/docs/oms_config_template.md b/docs/oms_template_config.md similarity index 74% rename from docs/oms_config_template.md rename to docs/oms_template_config.md index 696f7f32..3cfb1ffb 100644 --- a/docs/oms_config_template.md +++ b/docs/oms_template_config.md @@ -1,4 +1,4 @@ -## oms config template +## oms template config Render a config.yaml template using secrets from a vault file @@ -9,14 +9,14 @@ Render a config.yaml template using secrets from a prod.vault.yaml file. This command prints the rendered configuration to stdout so templating can be tested without running an installation. ``` -oms config template [flags] +oms template config [flags] ``` ### Examples ``` # Render config.yaml with secrets from prod.vault.yaml -$ oms config template --config config.yaml --vault prod.vault.yaml --age-key age_key.txt +$ oms template config --config config.yaml --vault prod.vault.yaml --age-key age_key.txt ``` @@ -25,11 +25,11 @@ $ oms config template --config config.yaml --vault prod.vault.yaml --age-key age ``` -k, --age-key string Path to the age key file used to decrypt the vault (required) -c, --config string Path to the config.yaml template to render (required) - -h, --help help for template + -h, --help help for config -v, --vault string Path to the SOPS-encrypted prod.vault.yaml file (required) ``` ### SEE ALSO -* [oms config](oms_config.md) - Work with OMS configuration files +* [oms template](oms_template.md) - Render OMS configuration templates diff --git a/internal/configtemplating/config_template.go b/internal/configtemplating/config_template.go index a4d980f2..8183f440 100644 --- a/internal/configtemplating/config_template.go +++ b/internal/configtemplating/config_template.go @@ -15,6 +15,9 @@ type SecretStore interface { LookupSecret(name string, selector ...string) (string, error) } +// RenderInstallConfigTemplate renders the given config template data, resolving +// any `secret` template calls against store, and returns the rendered bytes. +// Referencing a missing template key or a missing secret is treated as an error. func RenderInstallConfigTemplate(data []byte, store SecretStore) ([]byte, error) { tmpl, err := template.New("install-config"). Option("missingkey=error"). @@ -39,10 +42,10 @@ func RenderInstallConfigTemplate(data []byte, store SecretStore) ([]byte, error) return rendered.Bytes(), nil } -// RenderConfigFileToTempIfNeeded renders configPath with store into a temporary +// RenderConfigFileToTemp renders configPath with store into a temporary // 0600 YAML file and returns that path plus a cleanup function. Callers should // pass the returned path to downstream code and defer cleanup immediately. -func RenderConfigFileToTempIfNeeded(configPath string, store SecretStore) (string, func(), error) { +func RenderConfigFileToTemp(configPath string, store SecretStore) (string, func(), error) { data, err := os.ReadFile(configPath) if err != nil { return "", nil, fmt.Errorf("failed to read config file %s: %w", configPath, err) diff --git a/internal/installer/config_template_test.go b/internal/installer/config_template_test.go index b7a66f3e..213a0266 100644 --- a/internal/installer/config_template_test.go +++ b/internal/installer/config_template_test.go @@ -4,6 +4,7 @@ package installer_test import ( + "fmt" "os" "os/exec" "path/filepath" @@ -82,12 +83,13 @@ var _ = Describe("Config templating", func() { plaintextVaultPath := filepath.Join(tempDir, "prod.vault.plain.yaml") ageKeyPath := filepath.Join(tempDir, "age_key.txt") - Expect(os.WriteFile(configPath, []byte(`secrets: - baseDir: "`+tempDir+`" + configYaml := fmt.Sprintf(`secrets: + baseDir: %q codesphere: override: apiToken: "{{ secret "apiToken" }}" -`), 0644)).To(Succeed()) +`, tempDir) + Expect(os.WriteFile(configPath, []byte(configYaml), 0644)).To(Succeed()) vaultYaml, err := vault.Marshal() Expect(err).NotTo(HaveOccurred()) Expect(os.WriteFile(plaintextVaultPath, vaultYaml, 0600)).To(Succeed()) @@ -96,7 +98,7 @@ codesphere: Expect(err).NotTo(HaveOccurred()) Expect(installer.EncryptFileWithSOPS(plaintextVaultPath, vaultPath, strings.TrimSpace(string(recipient)))).To(Succeed()) - renderedPath, cleanup, err := configtemplating.RenderConfigFileToTempIfNeeded( + renderedPath, cleanup, err := configtemplating.RenderConfigFileToTemp( configPath, installer.NewLazyVaultTemplatingSecretStore(vaultPath, ageKeyPath), ) diff --git a/internal/installer/vault_templating_secret_store.go b/internal/installer/vault_templating_secret_store.go index eae88b64..99055557 100644 --- a/internal/installer/vault_templating_secret_store.go +++ b/internal/installer/vault_templating_secret_store.go @@ -12,16 +12,22 @@ import ( "go.yaml.in/yaml/v3" ) +// VaultTemplatingSecretStore resolves secrets referenced from config templates +// 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 } +// NewVaultTemplatingSecretStore returns a store backed by an already-decrypted vault. func NewVaultTemplatingSecretStore(vault *files.InstallVault) *VaultTemplatingSecretStore { return &VaultTemplatingSecretStore{vault: vault} } +// 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 { return &VaultTemplatingSecretStore{ vaultPath: vaultPath, @@ -29,6 +35,8 @@ func NewLazyVaultTemplatingSecretStore(vaultPath, ageKeyPath string) *VaultTempl } } +// 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) if err != nil { @@ -37,19 +45,12 @@ func NewVaultTemplatingSecretStoreFromFile(vaultPath, ageKeyPath string) (*Vault return NewVaultTemplatingSecretStore(vault), nil } +// LookupSecret returns the value of the named secret, optionally narrowed by a +// field selector (e.g. "password", "file.content"). The vault is loaded lazily +// on first use when the store was created without a preloaded vault. func (s *VaultTemplatingSecretStore) LookupSecret(name string, selector ...string) (string, error) { - if s == nil { - return "", errors.New("vault secret store is not configured") - } - if s.vault == nil { - if s.vaultPath == "" { - return "", errors.New("vault secret store is not configured") - } - vault, err := LoadVaultData(s.vaultPath, s.ageKeyPath) - if err != nil { - return "", err - } - s.vault = vault + if err := s.ensureVault(); err != nil { + return "", err } for _, entry := range s.vault.Secrets { @@ -61,6 +62,23 @@ func (s *VaultTemplatingSecretStore) LookupSecret(name string, selector ...strin return "", fmt.Errorf("secret %q not found in vault", name) } +// ensureVault lazily decrypts and loads the vault from disk when the store was +// created without a preloaded vault (see NewLazyVaultTemplatingSecretStore). +func (s *VaultTemplatingSecretStore) ensureVault() error { + if s.vault != nil { + return nil + } + if s.vaultPath == "" { + return errors.New("error initializing vault: vaultPath not set") + } + vault, err := LoadVaultData(s.vaultPath, s.ageKeyPath) + if err != nil { + return fmt.Errorf("error initializing vault: %w", err) + } + s.vault = vault + return nil +} + func selectVaultSecretValue(entry files.SecretEntry, selector ...string) (string, error) { field := "" if len(selector) > 0 { @@ -94,6 +112,8 @@ 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 { @@ -138,6 +158,8 @@ func isSOPSEncryptedYAML(data []byte) (bool, error) { return false, nil } + // A mapping node stores its keys and values as a flat list alternating + // key, value, key, value, ... so we step by 2 to visit each key/value pair. for i := 0; i+1 < len(root.Content); i += 2 { if root.Content[i].Value == "sops" && root.Content[i+1].Kind == yaml.MappingNode { return true, nil From 74cb16238e96268158ee0d646e8b85299459a496 Mon Sep 17 00:00:00 2001 From: Tim Schrodi Date: Tue, 2 Jun 2026 20:59:43 +0200 Subject: [PATCH 4/5] review Signed-off-by: Tim Schrodi --- cli/cmd/template_config.go | 16 +++++++++++++++- .../installer/vault_templating_secret_store.go | 6 +++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/cli/cmd/template_config.go b/cli/cmd/template_config.go index 81bb6e97..7cdd00b9 100644 --- a/cli/cmd/template_config.go +++ b/cli/cmd/template_config.go @@ -50,7 +50,21 @@ func AddTemplateConfigCmd(parentCmd *cobra.Command, opts *GlobalOptions) { Short: "Render a config.yaml template using secrets from a vault file", Long: io.Long(`Render a config.yaml template using secrets from a prod.vault.yaml file. -This command prints the rendered configuration to stdout so templating can be tested without running an installation.`), +This command prints the rendered configuration to stdout so templating can be tested without running an installation. + +Template syntax in config.yaml: + + # Inject a secret value (defaults to the "content"/"password" field) + someKey: "{{ secret "mySecret" }}" + + # Select a specific field + username: "{{ secret "mySecret" "fields.username" }}" + password: "{{ secret "mySecret" "fields.password" }}" + + # Inject a file secret's content + caCert: "{{ secret "caCert" "file.content" }}" + +Secret names and selectors must match entries in the prod.vault.yaml file.`), Example: formatExamples("template config", []io.Example{ { Cmd: "--config config.yaml --vault prod.vault.yaml --age-key age_key.txt", diff --git a/internal/installer/vault_templating_secret_store.go b/internal/installer/vault_templating_secret_store.go index 99055557..0179a4e5 100644 --- a/internal/installer/vault_templating_secret_store.go +++ b/internal/installer/vault_templating_secret_store.go @@ -50,7 +50,7 @@ func NewVaultTemplatingSecretStoreFromFile(vaultPath, ageKeyPath string) (*Vault // on first use when the store was created without a preloaded vault. func (s *VaultTemplatingSecretStore) LookupSecret(name string, selector ...string) (string, error) { if err := s.ensureVault(); err != nil { - return "", err + return "", fmt.Errorf("error ensuring the vault: %w", err) } for _, entry := range s.vault.Secrets { @@ -69,11 +69,11 @@ func (s *VaultTemplatingSecretStore) ensureVault() error { return nil } if s.vaultPath == "" { - return errors.New("error initializing vault: vaultPath not set") + return errors.New("vaultPath not set") } vault, err := LoadVaultData(s.vaultPath, s.ageKeyPath) if err != nil { - return fmt.Errorf("error initializing vault: %w", err) + return err } s.vault = vault return nil From 2fd0f390f3b94b28e1b8b8db260f11a951b309b9 Mon Sep 17 00:00:00 2001 From: schrodit <7979201+schrodit@users.noreply.github.com> Date: Tue, 2 Jun 2026 19:01:31 +0000 Subject: [PATCH 5/5] chore(docs): Auto-update docs and licenses Signed-off-by: schrodit <7979201+schrodit@users.noreply.github.com> --- NOTICE | 2 +- docs/oms_template_config.md | 14 ++++++++++++++ internal/tmpl/NOTICE | 2 +- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/NOTICE b/NOTICE index 6c14be3a..9e95c786 100644 --- a/NOTICE +++ b/NOTICE @@ -67,7 +67,7 @@ License URL: https://gitea.com/gitea/go-sdk/src/tag/gitea/v0.25.1/gitea/LICENSE Module: dario.cat/mergo Version: v1.0.2 License: BSD-3-Clause -License URL: https://github.com/imdario/mergo/blob/v1.0.2/LICENSE +License URL: Unknown ---------- Module: filippo.io/age diff --git a/docs/oms_template_config.md b/docs/oms_template_config.md index 3cfb1ffb..f8874561 100644 --- a/docs/oms_template_config.md +++ b/docs/oms_template_config.md @@ -8,6 +8,20 @@ Render a config.yaml template using secrets from a prod.vault.yaml file. This command prints the rendered configuration to stdout so templating can be tested without running an installation. +Template syntax in config.yaml: + + # Inject a secret value (defaults to the "content"/"password" field) + someKey: "{{ secret "mySecret" }}" + + # Select a specific field + username: "{{ secret "mySecret" "fields.username" }}" + password: "{{ secret "mySecret" "fields.password" }}" + + # Inject a file secret's content + caCert: "{{ secret "caCert" "file.content" }}" + +Secret names and selectors must match entries in the prod.vault.yaml file. + ``` oms template config [flags] ``` diff --git a/internal/tmpl/NOTICE b/internal/tmpl/NOTICE index 6c14be3a..9e95c786 100644 --- a/internal/tmpl/NOTICE +++ b/internal/tmpl/NOTICE @@ -67,7 +67,7 @@ License URL: https://gitea.com/gitea/go-sdk/src/tag/gitea/v0.25.1/gitea/LICENSE Module: dario.cat/mergo Version: v1.0.2 License: BSD-3-Clause -License URL: https://github.com/imdario/mergo/blob/v1.0.2/LICENSE +License URL: Unknown ---------- Module: filippo.io/age