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..787b54ff 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 diff --git a/docs/oms.md b/docs/oms.md index c3a10ccc..787b54ff 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 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..23f6dd86 100644 --- a/docs/oms_install_codesphere.md +++ b/docs/oms_install_codesphere.md @@ -33,6 +33,7 @@ $ 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 +}