From 375bb2fb3d3ac3ef7a6c9817bac92237d3142152 Mon Sep 17 00:00:00 2001 From: Dov Benyomin Sohacheski Date: Thu, 8 Jan 2026 23:10:59 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=97=9D=EF=B8=8F=20Add=20vault=20subcomman?= =?UTF-8?q?d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/feature/install.go | 10 +- cmd/feature/list.go | 9 +- cmd/info/env.go | 2 +- cmd/info/uptime.go | 2 +- cmd/logs/logs.go | 18 +- cmd/secrets/decrypt.go | 14 +- cmd/secrets/encrypt.go | 8 +- cmd/secrets/secrets.go | 6 +- cmd/secrets/vault.go | 86 +++++++ cmd/show/show_test.go | 5 +- cmd/template/apply.go | 13 +- cmd/template/list.go | 2 +- internals/config/defaults.go | 2 + internals/config/defaults_test.go | 1 + internals/env/env.go | 7 + internals/path/support.go | 16 ++ internals/path/support_test.go | 48 ++++ internals/secrets/crypto.go | 9 + internals/secrets/vault.go | 254 +++++++++++++++++++++ internals/secrets/vault_test.go | 366 ++++++++++++++++++++++++++++++ internals/styles/output.go | 35 +++ 21 files changed, 872 insertions(+), 41 deletions(-) create mode 100644 cmd/secrets/vault.go create mode 100644 internals/secrets/vault.go create mode 100644 internals/secrets/vault_test.go diff --git a/cmd/feature/install.go b/cmd/feature/install.go index 0747787..a9d8072 100644 --- a/cmd/feature/install.go +++ b/cmd/feature/install.go @@ -82,13 +82,15 @@ func installFeatureByName(cmd *cobra.Command, featureName, featuresDir string) e } if err := runAnsiblePlaybook(featurePath, vars); err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "\n%s\n", styles.ErrorBadge().Render("ERROR")) - fmt.Fprintf(cmd.ErrOrStderr(), "%s\n", styles.Error().Render(err.Error())) + fmt.Fprintln(cmd.ErrOrStderr()) + styles.PrintError(cmd.ErrOrStderr(), err.Error()) os.Exit(1) } - fmt.Fprintf(cmd.OutOrStdout(), "\n%s\n", styles.Success().Render("✓ Feature installed successfully")) - fmt.Fprintf(cmd.OutOrStdout(), " %s %s\n", styles.Key().Render("Feature:"), styles.Value().Render(featureName)) + fmt.Fprintln(cmd.OutOrStdout()) + styles.PrintSuccessWithDetails(cmd.OutOrStdout(), "Feature installed successfully", [][]string{ + {"Feature", featureName}, + }) return nil } diff --git a/cmd/feature/list.go b/cmd/feature/list.go index 0bd839d..bd46ed6 100644 --- a/cmd/feature/list.go +++ b/cmd/feature/list.go @@ -21,11 +21,11 @@ var listCmd = &cobra.Command{ } if len(availableFeatures) == 0 { - fmt.Fprintln(cmd.OutOrStdout(), styles.Warning().Render("No features found")) + fmt.Fprintln(cmd.OutOrStdout(), styles.Warning().Render("⚠ No features found")) return nil } - fmt.Fprintf(cmd.OutOrStdout(), "%s\n", styles.TitleWithCount("Features available", len(availableFeatures))) + fmt.Fprintf(cmd.OutOrStdout(), "%s\n", styles.TitleWithCount("Features Available", len(availableFeatures))) maxNameLen := 0 for _, feature := range availableFeatures { @@ -45,6 +45,11 @@ var listCmd = &cobra.Command{ fmt.Fprintln(cmd.OutOrStdout(), styles.List(featureItems...)) + styles.PrintHints(cmd.OutOrStdout(), [][]string{ + {"ws-cli feature install ", "Install a feature"}, + {"ws-cli feature info ", "View feature details"}, + }) + return nil }, } diff --git a/cmd/info/env.go b/cmd/info/env.go index 4869ffd..71e6654 100644 --- a/cmd/info/env.go +++ b/cmd/info/env.go @@ -20,7 +20,7 @@ func showEnvironment(writer io.Writer) { } } - fmt.Fprintf(writer, "%s\n", styles.TitleWithCount("Workspace variables", len(wsVars))) + fmt.Fprintf(writer, "%s\n", styles.TitleWithCount("Workspace Variables", len(wsVars))) sort.Slice(wsVars, func(i, j int) bool { return wsVars[i][0] < wsVars[j][0] diff --git a/cmd/info/uptime.go b/cmd/info/uptime.go index 3c6c086..f95df5c 100644 --- a/cmd/info/uptime.go +++ b/cmd/info/uptime.go @@ -55,7 +55,7 @@ func showUptime(writer io.Writer) { started, running, err := readStartup() if err != nil { - fmt.Fprintf(writer, "%s\n", styles.Warning().Render("⚠ Could not read workspace startup time")) + styles.PrintWarning(writer, "Could not read workspace startup time") return } diff --git a/cmd/logs/logs.go b/cmd/logs/logs.go index 4975b27..f90e8a2 100644 --- a/cmd/logs/logs.go +++ b/cmd/logs/logs.go @@ -22,19 +22,18 @@ func execute(cmd *cobra.Command, args []string) error { level, _ := cmd.Flags().GetString("level") if level != "" && level != "info" && level != "warn" && level != "error" && level != "debug" { - fmt.Fprintf(cmd.ErrOrStderr(), "%s\n\n", styles.ErrorBadge().Render("ERROR")) - fmt.Fprintln(cmd.ErrOrStderr(), styles.Error().Render("Invalid log level. Must be one of:")) - fmt.Fprintf(cmd.ErrOrStderr(), " %s %s\n", styles.Code().Render("debug"), styles.Muted().Render("- Debug information")) - fmt.Fprintf(cmd.ErrOrStderr(), " %s %s\n", styles.Code().Render("info"), styles.Muted().Render("- General information")) - fmt.Fprintf(cmd.ErrOrStderr(), " %s %s\n", styles.Code().Render("warn"), styles.Muted().Render("- Warning messages")) - fmt.Fprintf(cmd.ErrOrStderr(), " %s %s\n", styles.Code().Render("error"), styles.Muted().Render("- Error messages only")) + styles.PrintErrorWithOptions(cmd.ErrOrStderr(), "Invalid log level. Must be one of:", [][]string{ + {"debug", "Debug information"}, + {"info", "General information"}, + {"warn", "Warning messages"}, + {"error", "Error messages only"}, + }) os.Exit(1) } reader, err := logger.NewReader(tail, level) if err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "%s\n\n", styles.ErrorBadge().Render("ERROR")) - fmt.Fprintln(cmd.ErrOrStderr(), styles.Error().Render(fmt.Sprintf("Failed to initialize log reader: %s", err))) + styles.PrintError(cmd.ErrOrStderr(), fmt.Sprintf("Failed to initialize log reader: %s", err)) os.Exit(1) } @@ -45,8 +44,7 @@ func execute(cmd *cobra.Command, args []string) error { } if err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "%s\n\n", styles.ErrorBadge().Render("ERROR")) - fmt.Fprintln(cmd.ErrOrStderr(), styles.Error().Render(fmt.Sprintf("Error reading logs: %s", err))) + styles.PrintError(cmd.ErrOrStderr(), fmt.Sprintf("Error reading logs: %s", err)) os.Exit(1) } diff --git a/cmd/secrets/decrypt.go b/cmd/secrets/decrypt.go index 0fb60e5..b036761 100644 --- a/cmd/secrets/decrypt.go +++ b/cmd/secrets/decrypt.go @@ -2,7 +2,6 @@ package secrets import ( "fmt" - "strings" internalIO "github.com/kloudkit/ws-cli/internals/io" internalSecrets "github.com/kloudkit/ws-cli/internals/secrets" @@ -31,10 +30,7 @@ var decryptCmd = &cobra.Command{ return err } - input = strings.ReplaceAll(input, "\r", "") - input = strings.ReplaceAll(input, "\n", "") - input = strings.ReplaceAll(input, " ", "") - input = strings.ReplaceAll(input, "\t", "") + input = internalSecrets.NormalizeEncrypted(input) decrypted, err := internalSecrets.Decrypt(input, masterKey) if err != nil { @@ -45,8 +41,8 @@ var decryptCmd = &cobra.Command{ if raw { fmt.Fprint(cmd.OutOrStdout(), string(decrypted)) } else { - fmt.Fprintf(cmd.OutOrStdout(), "%s\n", styles.Header().Render("Decrypted Value")) - fmt.Fprintf(cmd.OutOrStdout(), " %s\n", styles.Code().Render(string(decrypted))) + styles.PrintTitle(cmd.OutOrStdout(), "Decrypted Value") + styles.PrintKeyCode(cmd.OutOrStdout(), "Value", string(decrypted)) } return nil } @@ -56,7 +52,9 @@ var decryptCmd = &cobra.Command{ } if !raw { - fmt.Fprintln(cmd.OutOrStdout(), styles.Success().Render(fmt.Sprintf("✓ Decrypted value written to %s", outputFile))) + styles.PrintSuccessWithDetailsCode(cmd.OutOrStdout(), "Secret decrypted successfully", [][]string{ + {"Output", outputFile}, + }) } return nil }, diff --git a/cmd/secrets/encrypt.go b/cmd/secrets/encrypt.go index 87be18e..069a3ea 100644 --- a/cmd/secrets/encrypt.go +++ b/cmd/secrets/encrypt.go @@ -42,8 +42,8 @@ var encryptCmd = &cobra.Command{ if raw { fmt.Fprintln(cmd.OutOrStdout(), encrypted) } else { - fmt.Fprintf(cmd.OutOrStdout(), "%s\n", styles.Header().Render("Encrypted Value")) - fmt.Fprintf(cmd.OutOrStdout(), " %s\n", styles.Code().Render(encrypted)) + styles.PrintTitle(cmd.OutOrStdout(), "Encrypted Value") + styles.PrintKeyCode(cmd.OutOrStdout(), "Value", encrypted) } return nil } @@ -53,7 +53,9 @@ var encryptCmd = &cobra.Command{ } if !raw { - fmt.Fprintln(cmd.OutOrStdout(), styles.Success().Render(fmt.Sprintf("✓ Encrypted value written to %s", outputFile))) + styles.PrintSuccessWithDetailsCode(cmd.OutOrStdout(), "Secret encrypted successfully", [][]string{ + {"Output", outputFile}, + }) } return nil }, diff --git a/cmd/secrets/secrets.go b/cmd/secrets/secrets.go index 5af5444..642b69f 100644 --- a/cmd/secrets/secrets.go +++ b/cmd/secrets/secrets.go @@ -11,9 +11,9 @@ var SecretsCmd = &cobra.Command{ func init() { SecretsCmd.PersistentFlags().String("master", "", "Master key or path to key file") - SecretsCmd.PersistentFlags().String("mode", "", "File permissions (e.g., 0o600, 384) - only when --output is used") - SecretsCmd.PersistentFlags().Bool("force", false, "Overwrite existing files/values") + SecretsCmd.PersistentFlags().String("mode", "", "File permissions (e.g., 0o600, 384), only when --output is used") + SecretsCmd.PersistentFlags().Bool("force", false, "Overwrite existing files") SecretsCmd.PersistentFlags().Bool("raw", false, "Output without styling") - SecretsCmd.AddCommand(encryptCmd, decryptCmd, generateCmd) + SecretsCmd.AddCommand(encryptCmd, decryptCmd, generateCmd, vaultCmd) } diff --git a/cmd/secrets/vault.go b/cmd/secrets/vault.go new file mode 100644 index 0000000..5409e2e --- /dev/null +++ b/cmd/secrets/vault.go @@ -0,0 +1,86 @@ +package secrets + +import ( + "fmt" + "strings" + + internalSecrets "github.com/kloudkit/ws-cli/internals/secrets" + "github.com/kloudkit/ws-cli/internals/styles" + "github.com/spf13/cobra" +) + +var vaultCmd = &cobra.Command{ + Use: "vault", + Short: "Decrypt a vault spec with encrypted values", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + inputFile, _ := cmd.Flags().GetString("input") + masterKeyFlag, _ := cmd.Flags().GetString("master") + keys, _ := cmd.Flags().GetStringArray("key") + force, _ := cmd.Flags().GetBool("force") + raw, _ := cmd.Flags().GetBool("raw") + stdout, _ := cmd.Flags().GetBool("stdout") + modeOverride, _ := cmd.Flags().GetString("mode") + + vaultPath, err := internalSecrets.ResolveVaultPath(inputFile) + if err != nil { + return err + } + + masterKey, err := internalSecrets.ResolveMasterKey(masterKeyFlag) + if err != nil { + return err + } + + vault, err := internalSecrets.LoadVault(vaultPath) + if err != nil { + return err + } + + opts := internalSecrets.ProcessOptions{ + MasterKey: masterKey, + Keys: keys, + Stdout: stdout, + Raw: raw, + Force: force, + ModeOverride: modeOverride, + } + + results, err := internalSecrets.ProcessVault(vault, opts) + if err != nil { + return err + } + + if stdout { + for key, value := range results { + output := internalSecrets.FormatSecretForStdout(key, value, raw) + fmt.Fprint(cmd.OutOrStdout(), output) + } + return nil + } + + if !raw { + fmt.Fprintln(cmd.OutOrStdout(), styles.Success().Render("✓ Vault processed successfully")) + for key, dest := range results { + if strings.HasPrefix(dest, "env:") { + envVar := strings.TrimPrefix(dest, "env:") + fmt.Fprintf(cmd.OutOrStdout(), " %s → %s\n", + styles.Code().Render(key), + styles.Muted().Render(fmt.Sprintf("env:%s", envVar))) + } else { + fmt.Fprintf(cmd.OutOrStdout(), " %s → %s\n", + styles.Code().Render(key), + styles.Muted().Render(dest)) + } + } + } + + return nil + }, +} + +func init() { + vaultCmd.Flags().String("input", "", "Path to vault file") + vaultCmd.Flags().StringArray("key", []string{}, "Decrypt only specified key") + vaultCmd.Flags().Bool("stdout", false, "Output decrypted values to stdout") +} diff --git a/cmd/show/show_test.go b/cmd/show/show_test.go index 1934c22..2c9e5ff 100644 --- a/cmd/show/show_test.go +++ b/cmd/show/show_test.go @@ -2,7 +2,6 @@ package show import ( "bytes" - "os" "strings" "testing" @@ -18,7 +17,7 @@ func TestPathHome(t *testing.T) { }) t.Run("WithoutEnv", func(t *testing.T) { - os.Unsetenv(config.EnvServerRoot) + t.Setenv(config.EnvServerRoot, "") assertOutputContains(t, []string{"path", "home"}, "/workspace") }) @@ -32,7 +31,7 @@ func TestPathVscode(t *testing.T) { }) t.Run("WithoutEnv", func(t *testing.T) { - os.Unsetenv("HOME") + t.Setenv("HOME", "") assertOutputContains(t, []string{"path", "vscode-settings"}, "/home/kloud/.local/share/workspace/User/settings.json") }) diff --git a/cmd/template/apply.go b/cmd/template/apply.go index 10ce168..9f44a50 100644 --- a/cmd/template/apply.go +++ b/cmd/template/apply.go @@ -28,11 +28,14 @@ func runApply(cmd *cobra.Command, args []string) error { config, _ := template.GetTemplate(templateName) sourcePath := template.SupportedTemplates[templateName].SourcePath - fmt.Fprintf(cmd.OutOrStdout(), "%s\n", styles.Success().Render("✓ Template applied successfully")) - fmt.Fprintf(cmd.OutOrStdout(), " %s %s\n", styles.Key().Render("Template:"), styles.Value().Render(templateName)) - fmt.Fprintf(cmd.OutOrStdout(), " %s %s\n", styles.Key().Render("Source:"), styles.Code().Render(sourcePath)) - fmt.Fprintf(cmd.OutOrStdout(), " %s %s\n", styles.Key().Render("Target:"), styles.Code().Render(fmt.Sprintf("%s/%s", targetPath, config.OutputName))) - fmt.Fprintf(cmd.OutOrStdout(), "\n%s\n", styles.Muted().Render("Use 'ws-cli template show --local "+templateName+"' to view the applied template")) + styles.PrintSuccess(cmd.OutOrStdout(), "Template applied successfully") + styles.PrintKeyValue(cmd.OutOrStdout(), "Template", templateName) + styles.PrintKeyCode(cmd.OutOrStdout(), "Source", sourcePath) + styles.PrintKeyCode(cmd.OutOrStdout(), "Target", fmt.Sprintf("%s/%s", targetPath, config.OutputName)) + + styles.PrintHints(cmd.OutOrStdout(), [][]string{ + {"ws-cli template show --local " + templateName, "View applied template"}, + }) return nil } diff --git a/cmd/template/list.go b/cmd/template/list.go index 3ddc422..b67685d 100644 --- a/cmd/template/list.go +++ b/cmd/template/list.go @@ -21,7 +21,7 @@ func runList(cmd *cobra.Command, args []string) error { templates := template.SupportedTemplates names := template.GetTemplateNames() - fmt.Fprintf(cmd.OutOrStdout(), "%s\n", styles.TitleWithCount("Templates available", len(names))) + fmt.Fprintf(cmd.OutOrStdout(), "%s\n", styles.TitleWithCount("Templates Available", len(names))) listItems := make([]any, len(names)) for i, name := range names { diff --git a/internals/config/defaults.go b/internals/config/defaults.go index 931c724..d2576a7 100644 --- a/internals/config/defaults.go +++ b/internals/config/defaults.go @@ -3,6 +3,7 @@ package config const ( EnvSecretsKey = "WS_SECRETS_MASTER_KEY" EnvSecretsKeyFile = "WS_SECRETS_MASTER_KEY_FILE" + EnvSecretsVault = "WS_SECRETS_VAULT" EnvLoggingDir = "WS_LOGGING_DIR" EnvLoggingFile = "WS_LOGGING_MAIN_FILE" EnvServerRoot = "WS_SERVER_ROOT" @@ -10,6 +11,7 @@ const ( EnvIPCSocket = "WS__INTERNAL_IPC_SOCKET" DefaultSecretsKeyPath = "/etc/workspace/master.key" + DefaultEnvFilePath = "~/.zshenv" DefaultLoggingDir = "/var/log/workspace" DefaultLoggingFile = "workspace.log" DefaultServerRoot = "/workspace" diff --git a/internals/config/defaults_test.go b/internals/config/defaults_test.go index 203debf..3295e3f 100644 --- a/internals/config/defaults_test.go +++ b/internals/config/defaults_test.go @@ -9,6 +9,7 @@ import ( func TestConstants(t *testing.T) { assert.Equal(t, "WS_SECRETS_MASTER_KEY", EnvSecretsKey) assert.Equal(t, "WS_SECRETS_MASTER_KEY_FILE", EnvSecretsKeyFile) + assert.Equal(t, "WS_SECRETS_VAULT", EnvSecretsVault) assert.Equal(t, "WS_LOGGING_DIR", EnvLoggingDir) assert.Equal(t, "WS_LOGGING_MAIN_FILE", EnvLoggingFile) assert.Equal(t, "WS_SERVER_ROOT", EnvServerRoot) diff --git a/internals/env/env.go b/internals/env/env.go index 11840de..3a73c88 100644 --- a/internals/env/env.go +++ b/internals/env/env.go @@ -2,6 +2,7 @@ package env import ( "os" + "regexp" "strings" ) @@ -43,3 +44,9 @@ func GetAll() map[string]string { return result } + +func IsValidName(name string) bool { + return regexp. + MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`). + MatchString(name) +} diff --git a/internals/path/support.go b/internals/path/support.go index 2b34efc..8170cfa 100644 --- a/internals/path/support.go +++ b/internals/path/support.go @@ -3,6 +3,7 @@ package path import ( "fmt" "os" + "path/filepath" "regexp" "strings" @@ -37,6 +38,21 @@ func ResolveConfigPath(configPath string) string { return GetHomeDirectory(configPath) } +func Expand(path string) (string, error) { + path = os.ExpandEnv(path) + path = filepath.Clean(path) + + if strings.HasPrefix(path, "~") { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + path = filepath.Join(homeDir, path[1:]) + } + + return path, nil +} + func GetCurrentWorkingDirectory(segments ...string) (string, error) { cwd, err := os.Getwd() if err != nil { diff --git a/internals/path/support_test.go b/internals/path/support_test.go index 31b38a1..35c1992 100644 --- a/internals/path/support_test.go +++ b/internals/path/support_test.go @@ -44,6 +44,54 @@ func TestResolveConfigPath(t *testing.T) { }) } +func TestExpand(t *testing.T) { + t.Run("AbsolutePath", func(t *testing.T) { + result, err := Expand("/etc/config") + assert.NilError(t, err) + assert.Equal(t, "/etc/config", result) + }) + + t.Run("TildePath", func(t *testing.T) { + homeDir, err := os.UserHomeDir() + assert.NilError(t, err) + + result, err := Expand("~/.config") + assert.NilError(t, err) + assert.Equal(t, homeDir+"/.config", result) + }) + + t.Run("TildeOnly", func(t *testing.T) { + homeDir, err := os.UserHomeDir() + assert.NilError(t, err) + + result, err := Expand("~") + assert.NilError(t, err) + assert.Equal(t, homeDir, result) + }) + + t.Run("EnvVar", func(t *testing.T) { + t.Setenv("TEST_DIR", "/var/test") + + result, err := Expand("$TEST_DIR/config") + assert.NilError(t, err) + assert.Equal(t, "/var/test/config", result) + }) + + t.Run("EnvVarAndTilde", func(t *testing.T) { + t.Setenv("HOME", "/home/testuser") + + result, err := Expand("~/data") + assert.NilError(t, err) + assert.Equal(t, "/home/testuser/data", result) + }) + + t.Run("CleanPath", func(t *testing.T) { + result, err := Expand("/etc//config/../config/app") + assert.NilError(t, err) + assert.Equal(t, "/etc/config/app", result) + }) +} + func TestGetCurrentWorkingDirectory(t *testing.T) { t.Run("WithoutSegments", func(t *testing.T) { result, err := GetCurrentWorkingDirectory() diff --git a/internals/secrets/crypto.go b/internals/secrets/crypto.go index 936b0ab..502181e 100644 --- a/internals/secrets/crypto.go +++ b/internals/secrets/crypto.go @@ -44,6 +44,15 @@ func Encrypt(plainText []byte, masterKey []byte) (string, error) { base64.RawStdEncoding.EncodeToString(cipherText)), nil } +func NormalizeEncrypted(encrypted string) string { + encrypted = strings.TrimSpace(encrypted) + encrypted = strings.ReplaceAll(encrypted, "\r", "") + encrypted = strings.ReplaceAll(encrypted, "\n", "") + encrypted = strings.ReplaceAll(encrypted, " ", "") + encrypted = strings.ReplaceAll(encrypted, "\t", "") + return encrypted +} + func Decrypt(encodedValue string, masterKey []byte) ([]byte, error) { parts := strings.Split(encodedValue, "$") if len(parts) != 2 { diff --git a/internals/secrets/vault.go b/internals/secrets/vault.go new file mode 100644 index 0000000..bdca735 --- /dev/null +++ b/internals/secrets/vault.go @@ -0,0 +1,254 @@ +package secrets + +import ( + "fmt" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/kloudkit/ws-cli/internals/config" + "github.com/kloudkit/ws-cli/internals/env" + internalIO "github.com/kloudkit/ws-cli/internals/io" + "github.com/kloudkit/ws-cli/internals/path" + "gopkg.in/yaml.v3" +) + +type VaultSecret struct { + Type string `yaml:"type,omitempty"` + Encrypted string `yaml:"encrypted"` + Destination string `yaml:"destination"` + Mode string `yaml:"mode,omitempty"` + Force bool `yaml:"force,omitempty"` +} + +type Vault struct { + Secrets map[string]VaultSecret `yaml:"secrets"` +} + +const ( + TypeGeneric = "generic" + TypeSSH = "ssh" + TypeEnv = "env" + TypeKubeconfig = "kubeconfig" + TypeDockerConfigJSON = "dockerconfigjson" +) + +var DefaultModeByType = map[string]string{ + TypeGeneric: "0o600", + TypeSSH: "0o600", + TypeEnv: "0o644", + TypeKubeconfig: "0o600", + TypeDockerConfigJSON: "0o600", +} + +func LoadVault(path string) (*Vault, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read vault file %q: %w", path, err) + } + + var vault Vault + if err := yaml.Unmarshal(data, &vault); err != nil { + return nil, fmt.Errorf("failed to unmarshal vault yaml: %w", err) + } + + if vault.Secrets == nil { + vault.Secrets = make(map[string]VaultSecret) + } + + for name, secret := range vault.Secrets { + if secret.Type == "" { + secret.Type = TypeGeneric + } + + if secret.Mode == "" { + if mode, ok := DefaultModeByType[secret.Type]; ok { + secret.Mode = mode + } + } + + vault.Secrets[name] = secret + } + + return &vault, nil +} + +func ResolveVaultPath(inputFlag string) (string, error) { + if inputFlag != "" { + return inputFlag, nil + } + + if vaultPath := env.String(config.EnvSecretsVault); vaultPath != "" { + return vaultPath, nil + } + + return "", fmt.Errorf("vault file not specified (use --input or %s)", config.EnvSecretsVault) +} + +func ValidateSecret(name string, secret VaultSecret) error { + if secret.Encrypted == "" { + return fmt.Errorf("secret %q: encrypted value is required", name) + } + + if secret.Destination == "" { + return fmt.Errorf("secret %q: destination is required", name) + } + + validTypes := []string{ + TypeGeneric, + TypeSSH, + TypeEnv, + TypeKubeconfig, + TypeDockerConfigJSON, + } + + if !slices.Contains(validTypes, secret.Type) { + return fmt.Errorf("secret %q: invalid type %q", name, secret.Type) + } + + if secret.Type == TypeEnv { + if !env.IsValidName(secret.Destination) { + return fmt.Errorf("secret %q: invalid environment variable name %q (must start with letter/underscore and contain only alphanumerics and underscores)", name, secret.Destination) + } + } else if !filepath.IsAbs(secret.Destination) { + return fmt.Errorf("secret %q: destination must be an absolute path for type %q", name, secret.Type) + } + + return nil +} + +func GetSecretKeys(vault *Vault, requestedKeys []string) []string { + if len(requestedKeys) > 0 { + return requestedKeys + } + + keys := make([]string, 0, len(vault.Secrets)) + for key := range vault.Secrets { + keys = append(keys, key) + } + + return keys +} + +type ProcessOptions struct { + MasterKey []byte + Keys []string + Stdout bool + Raw bool + Force bool + ModeOverride string +} + +func ProcessVault(vault *Vault, opts ProcessOptions) (map[string]string, error) { + results := make(map[string]string) + keys := GetSecretKeys(vault, opts.Keys) + + for _, key := range keys { + secret, exists := vault.Secrets[key] + if !exists { + return nil, fmt.Errorf("secret %q not found in vault", key) + } + + if err := ValidateSecret(key, secret); err != nil { + return nil, err + } + + encryptedValue := NormalizeEncrypted(secret.Encrypted) + + decrypted, err := Decrypt(encryptedValue, opts.MasterKey) + if err != nil { + return nil, fmt.Errorf("failed to decrypt secret %q: %w", key, err) + } + + if opts.Stdout { + results[key] = string(decrypted) + continue + } + + mode := secret.Mode + if opts.ModeOverride != "" { + mode = opts.ModeOverride + } + + if secret.Type == TypeEnv { + if err := ProcessEnvSecret(secret.Destination, decrypted, opts.Force); err != nil { + return nil, fmt.Errorf("failed to process env secret %q: %w", key, err) + } + results[key] = fmt.Sprintf("env:%s", secret.Destination) + } else { + if err := internalIO.WriteSecureFile(secret.Destination, decrypted, mode, opts.Force); err != nil { + return nil, fmt.Errorf("failed to write secret %q: %w", key, err) + } + results[key] = secret.Destination + } + } + + return results, nil +} + +func findAndReplaceEnvVar(lines []string, envVarName, value string, force bool) ([]string, error) { + exportLine := fmt.Sprintf("export %s=%q", envVarName, value) + found := false + + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "export "+envVarName+"=") || + strings.HasPrefix(trimmed, envVarName+"=") { + if !force { + return nil, fmt.Errorf("environment variable %q already exists, use --force to overwrite", envVarName) + } + lines[i] = exportLine + found = true + break + } + } + + if !found { + lines = append(lines, exportLine) + } + + return lines, nil +} + +func ProcessEnvSecret(envVarName string, value []byte, force bool) error { + envFilePath, err := path.Expand(config.DefaultEnvFilePath) + if err != nil { + return err + } + + var existingContent []byte + if internalIO.FileExists(envFilePath) { + data, err := os.ReadFile(envFilePath) + if err != nil { + return fmt.Errorf("failed to read env file: %w", err) + } + existingContent = data + } + + lines := strings.Split(string(existingContent), "\n") + + lines, err = findAndReplaceEnvVar(lines, envVarName, string(value), force) + if err != nil { + return fmt.Errorf("%w in %s", err, envFilePath) + } + + content := strings.Join(lines, "\n") + if !strings.HasSuffix(content, "\n") { + content += "\n" + } + + if err := os.WriteFile(envFilePath, []byte(content), 0o644); err != nil { + return fmt.Errorf("failed to write env file: %w", err) + } + + return nil +} + +func FormatSecretForStdout(key string, value string, raw bool) string { + if raw { + return value + } + + return fmt.Sprintf("[%s]\n%s\n", key, value) +} diff --git a/internals/secrets/vault_test.go b/internals/secrets/vault_test.go new file mode 100644 index 0000000..8394921 --- /dev/null +++ b/internals/secrets/vault_test.go @@ -0,0 +1,366 @@ +package secrets + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "gotest.tools/v3/assert" +) + +func TestLoadVault(t *testing.T) { + t.Run("ValidVault", func(t *testing.T) { + vaultContent := ` +secrets: + db_password: + encrypted: "test$encrypted" + destination: "/etc/db/password" + ssh_key: + type: "ssh" + encrypted: "test$encrypted" + destination: "/home/user/.ssh/id_rsa" +` + vaultFile := filepath.Join(t.TempDir(), "vault.yaml") + err := os.WriteFile(vaultFile, []byte(vaultContent), 0600) + assert.NilError(t, err) + + vault, err := LoadVault(vaultFile) + assert.NilError(t, err) + assert.Equal(t, 2, len(vault.Secrets)) + assert.Equal(t, TypeGeneric, vault.Secrets["db_password"].Type) + assert.Equal(t, TypeSSH, vault.Secrets["ssh_key"].Type) + assert.Equal(t, "0o600", vault.Secrets["db_password"].Mode) + }) + + t.Run("EmptyVault", func(t *testing.T) { + vaultContent := `secrets: {}` + vaultFile := filepath.Join(t.TempDir(), "vault.yaml") + err := os.WriteFile(vaultFile, []byte(vaultContent), 0600) + assert.NilError(t, err) + + vault, err := LoadVault(vaultFile) + assert.NilError(t, err) + assert.Equal(t, 0, len(vault.Secrets)) + }) + + t.Run("InvalidYAML", func(t *testing.T) { + vaultContent := `invalid: yaml: content:` + vaultFile := filepath.Join(t.TempDir(), "vault.yaml") + err := os.WriteFile(vaultFile, []byte(vaultContent), 0600) + assert.NilError(t, err) + + _, err = LoadVault(vaultFile) + assert.ErrorContains(t, err, "failed to unmarshal") + }) + + t.Run("FileNotFound", func(t *testing.T) { + _, err := LoadVault("/nonexistent/vault.yaml") + assert.ErrorContains(t, err, "failed to read vault file") + }) +} + +func TestValidateSecret(t *testing.T) { + tests := []struct { + name string + secretName string + secret VaultSecret + errorContains string + }{ + { + name: "Valid", + secretName: "test", + secret: VaultSecret{ + Type: TypeGeneric, + Encrypted: "encrypted$value", + Destination: "/etc/test", + }, + errorContains: "", + }, + { + name: "MissingEncrypted", + secretName: "test", + secret: VaultSecret{ + Type: TypeGeneric, + Destination: "/etc/test", + }, + errorContains: "encrypted value is required", + }, + { + name: "MissingDestination", + secretName: "test", + secret: VaultSecret{ + Type: TypeGeneric, + Encrypted: "encrypted$value", + }, + errorContains: "destination is required", + }, + { + name: "InvalidType", + secretName: "test", + secret: VaultSecret{ + Type: "invalid", + Encrypted: "encrypted$value", + Destination: "/etc/test", + }, + errorContains: "invalid type", + }, + { + name: "RelativePathNonEnv", + secretName: "test", + secret: VaultSecret{ + Type: TypeGeneric, + Encrypted: "encrypted$value", + Destination: "relative/path", + }, + errorContains: "must be an absolute path", + }, + { + name: "EnvTypeValid", + secretName: "test", + secret: VaultSecret{ + Type: TypeEnv, + Encrypted: "encrypted$value", + Destination: "MY_VAR", + }, + errorContains: "", + }, + { + name: "EnvTypeValidUnderscore", + secretName: "test", + secret: VaultSecret{ + Type: TypeEnv, + Encrypted: "encrypted$value", + Destination: "_MY_VAR", + }, + errorContains: "", + }, + { + name: "EnvTypeValidWithNumbers", + secretName: "test", + secret: VaultSecret{ + Type: TypeEnv, + Encrypted: "encrypted$value", + Destination: "MY_VAR_123", + }, + errorContains: "", + }, + { + name: "EnvTypeInvalidStartsWithNumber", + secretName: "test", + secret: VaultSecret{ + Type: TypeEnv, + Encrypted: "encrypted$value", + Destination: "123_VAR", + }, + errorContains: "invalid environment variable name", + }, + { + name: "EnvTypeInvalidHyphen", + secretName: "test", + secret: VaultSecret{ + Type: TypeEnv, + Encrypted: "encrypted$value", + Destination: "MY-VAR", + }, + errorContains: "invalid environment variable name", + }, + { + name: "EnvTypeInvalidDot", + secretName: "test", + secret: VaultSecret{ + Type: TypeEnv, + Encrypted: "encrypted$value", + Destination: "MY.VAR", + }, + errorContains: "invalid environment variable name", + }, + { + name: "EnvTypeInvalidSpace", + secretName: "test", + secret: VaultSecret{ + Type: TypeEnv, + Encrypted: "encrypted$value", + Destination: "MY VAR", + }, + errorContains: "invalid environment variable name", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateSecret(tt.secretName, tt.secret) + if tt.errorContains != "" { + assert.ErrorContains(t, err, tt.errorContains) + } else { + assert.NilError(t, err) + } + }) + } +} + +func TestGetSecretKeys(t *testing.T) { + vault := &Vault{ + Secrets: map[string]VaultSecret{ + "key1": {}, + "key2": {}, + "key3": {}, + }, + } + + t.Run("AllKeys", func(t *testing.T) { + keys := GetSecretKeys(vault, []string{}) + assert.Equal(t, 3, len(keys)) + }) + + t.Run("SpecificKeys", func(t *testing.T) { + keys := GetSecretKeys(vault, []string{"key1", "key3"}) + assert.Equal(t, 2, len(keys)) + assert.Equal(t, "key1", keys[0]) + assert.Equal(t, "key3", keys[1]) + }) +} + +func TestResolveVaultPath(t *testing.T) { + t.Run("FromFlag", func(t *testing.T) { + path, err := ResolveVaultPath("/path/to/vault.yaml") + assert.NilError(t, err) + assert.Equal(t, "/path/to/vault.yaml", path) + }) + + t.Run("FromEnv", func(t *testing.T) { + t.Setenv("WS_SECRETS_VAULT", "/env/vault.yaml") + path, err := ResolveVaultPath("") + assert.NilError(t, err) + assert.Equal(t, "/env/vault.yaml", path) + }) + + t.Run("NotSpecified", func(t *testing.T) { + t.Setenv("WS_SECRETS_VAULT", "") + _, err := ResolveVaultPath("") + assert.ErrorContains(t, err, "vault file not specified") + }) +} + +func TestFormatSecretForStdout(t *testing.T) { + t.Run("Raw", func(t *testing.T) { + output := FormatSecretForStdout("key", "value", true) + assert.Equal(t, "value", output) + }) + + t.Run("Formatted", func(t *testing.T) { + output := FormatSecretForStdout("key", "value", false) + assert.Equal(t, "[key]\nvalue\n", output) + }) +} + +func TestProcessEnvSecret(t *testing.T) { + t.Run("NewVariable", func(t *testing.T) { + tmpDir := t.TempDir() + envFile := filepath.Join(tmpDir, ".zshenv") + + t.Setenv("HOME", tmpDir) + + err := ProcessEnvSecret("NEW_VAR", []byte("secret_value"), false) + assert.NilError(t, err) + + content, err := os.ReadFile(envFile) + assert.NilError(t, err) + assert.Assert(t, len(content) > 0) + }) + + t.Run("ExistingWithoutForce", func(t *testing.T) { + tmpDir := t.TempDir() + envFile := filepath.Join(tmpDir, ".zshenv") + + t.Setenv("HOME", tmpDir) + + initialContent := `export EXISTING_VAR="old_value" +` + err := os.WriteFile(envFile, []byte(initialContent), 0644) + assert.NilError(t, err) + + err = ProcessEnvSecret("EXISTING_VAR", []byte("new_value"), false) + assert.ErrorContains(t, err, "already exists") + }) + + t.Run("ExistingWithForce", func(t *testing.T) { + tmpDir := t.TempDir() + envFile := filepath.Join(tmpDir, ".zshenv") + + t.Setenv("HOME", tmpDir) + + initialContent := `export EXISTING_VAR="old_value" +` + err := os.WriteFile(envFile, []byte(initialContent), 0644) + assert.NilError(t, err) + + err = ProcessEnvSecret("EXISTING_VAR", []byte("new_value"), true) + assert.NilError(t, err) + + content, err := os.ReadFile(envFile) + assert.NilError(t, err) + assert.Assert(t, len(content) > 0) + }) + + t.Run("MultipleCalls", func(t *testing.T) { + tmpDir := t.TempDir() + envFile := filepath.Join(tmpDir, ".zshenv") + + t.Setenv("HOME", tmpDir) + + err := ProcessEnvSecret("VAR1", []byte("value1"), false) + assert.NilError(t, err) + + err = ProcessEnvSecret("VAR2", []byte("value2"), false) + assert.NilError(t, err) + + content, err := os.ReadFile(envFile) + assert.NilError(t, err) + + contentStr := string(content) + assert.Assert(t, strings.Contains(contentStr, `export VAR1="value1"`)) + assert.Assert(t, strings.Contains(contentStr, `export VAR2="value2"`)) + }) + + t.Run("DuplicateCallWithoutForce", func(t *testing.T) { + tmpDir := t.TempDir() + envFile := filepath.Join(tmpDir, ".zshenv") + + t.Setenv("HOME", tmpDir) + + err := ProcessEnvSecret("DUPLICATE_VAR", []byte("value1"), false) + assert.NilError(t, err) + + err = ProcessEnvSecret("DUPLICATE_VAR", []byte("value2"), false) + assert.ErrorContains(t, err, "already exists") + + content, err := os.ReadFile(envFile) + assert.NilError(t, err) + assert.Assert(t, strings.Contains(string(content), `export DUPLICATE_VAR="value1"`)) + assert.Assert(t, !strings.Contains(string(content), "value2")) + }) + + t.Run("DuplicateCallWithForce", func(t *testing.T) { + tmpDir := t.TempDir() + envFile := filepath.Join(tmpDir, ".zshenv") + + t.Setenv("HOME", tmpDir) + + err := ProcessEnvSecret("DUPLICATE_VAR", []byte("value1"), false) + assert.NilError(t, err) + + err = ProcessEnvSecret("DUPLICATE_VAR", []byte("value2"), true) + assert.NilError(t, err) + + content, err := os.ReadFile(envFile) + assert.NilError(t, err) + + contentStr := string(content) + assert.Assert(t, strings.Contains(contentStr, `export DUPLICATE_VAR="value2"`)) + assert.Assert(t, !strings.Contains(contentStr, "value1")) + + lines := strings.Split(strings.TrimSpace(contentStr), "\n") + assert.Equal(t, 1, len(lines)) + }) +} diff --git a/internals/styles/output.go b/internals/styles/output.go index 0ec9038..092311d 100644 --- a/internals/styles/output.go +++ b/internals/styles/output.go @@ -40,3 +40,38 @@ func PrintError(writer io.Writer, message string) { fmt.Fprintf(writer, "%s\n", ErrorBadge().Render("ERROR")) fmt.Fprintf(writer, "%s\n", Error().Render(message)) } + +func PrintErrorWithOptions(writer io.Writer, message string, options [][]string) { + PrintError(writer, message) + fmt.Fprintln(writer) + for _, opt := range options { + fmt.Fprintf(writer, " %s %s\n", Code().Render(opt[0]), Muted().Render(opt[1])) + } +} + +func PrintSuccessWithDetails(writer io.Writer, message string, details [][]string) { + PrintSuccess(writer, message) + for _, detail := range details { + if len(detail) >= 2 { + PrintKeyValue(writer, detail[0], detail[1]) + } + } +} + +func PrintSuccessWithDetailsCode(writer io.Writer, message string, details [][]string) { + PrintSuccess(writer, message) + for _, detail := range details { + if len(detail) >= 2 { + PrintKeyCode(writer, detail[0], detail[1]) + } + } +} + +func PrintHints(writer io.Writer, hints [][]string) { + fmt.Fprintf(writer, "\n%s\n", Muted().Render("Quick actions:")) + for _, hint := range hints { + if len(hint) >= 2 { + fmt.Fprintf(writer, " %s %s\n", Code().Render(hint[0]), Muted().Render(hint[1])) + } + } +}