Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 31 additions & 7 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package config
import (
"fmt"
"os"
"path/filepath"
"strings"

"github.com/spf13/cobra"
Expand Down Expand Up @@ -87,6 +88,7 @@ func BindFlags(cmd *cobra.Command) {
f.String("ssh-user", "", "SSH user for VMs")
f.String("ssh-password", "", "SSH password for VMs")
f.StringSlice("ssh-key", nil, "SSH authorized key (repeatable)")
f.StringSlice("ssh-key-file", nil, "SSH key file path (repeatable)")
}

// LoadConfig loads configuration from flags, environment variables, config file,
Expand Down Expand Up @@ -160,7 +162,11 @@ func LoadConfig(cmd *cobra.Command) (*Config, error) {
cfg.AuditDBPath = v.GetString("audit-db")

// Handle SSH authorized keys: CLI flags, env var (comma-split), or YAML list
cfg.SSHAuthorizedKeys = resolveSSHKeys(v, cmd)
sshKeys, err := resolveSSHKeys(v, cmd)
if err != nil {
return nil, err
}
cfg.SSHAuthorizedKeys = sshKeys

// Unmarshal workloads map if present in config file
workloads := make(map[string]WorkloadConfig)
Expand All @@ -184,15 +190,33 @@ func bindFlagIfSet(v *viper.Viper, cmd *cobra.Command, name string) {

// resolveSSHKeys resolves SSH authorized keys from CLI flags, env vars, or config file.
// Priority: CLI --ssh-key flags > VIRTWORK_SSH_AUTHORIZED_KEYS env var > YAML config.
func resolveSSHKeys(v *viper.Viper, cmd *cobra.Command) []string {
// CLI flags take highest priority
func resolveSSHKeys(v *viper.Viper, cmd *cobra.Command) ([]string, error) {
// CLI flags take highest priority — merge inline keys and file-based keys
var cliKeys []string

if cmd.Flags().Changed("ssh-key") {
keys, _ := cmd.Flags().GetStringSlice("ssh-key")
if len(keys) > 0 {
return keys
cliKeys = append(cliKeys, keys...)
}

if cmd.Flags().Changed("ssh-key-file") {
paths, _ := cmd.Flags().GetStringSlice("ssh-key-file")
for _, p := range paths {
data, err := os.ReadFile(filepath.Clean(p)) //nolint:gosec // CLI user supplies the path intentionally
if err != nil {
return nil, fmt.Errorf("reading SSH key file %s: %w", p, err)
}
key := strings.TrimSpace(string(data))
if key != "" {
cliKeys = append(cliKeys, key)
}
}
}

if len(cliKeys) > 0 {
return cliKeys, nil
}

// Check env var with comma splitting
envVal := os.Getenv("VIRTWORK_SSH_AUTHORIZED_KEYS")
if envVal != "" {
Expand All @@ -205,10 +229,10 @@ func resolveSSHKeys(v *viper.Viper, cmd *cobra.Command) []string {
}
}
if len(keys) > 0 {
return keys
return keys, nil
}
}

// Fall back to YAML config list
return v.GetStringSlice("ssh-authorized-keys")
return v.GetStringSlice("ssh-authorized-keys"), nil
}
73 changes: 73 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ func writeConfigFile(dir, content string) string {
return path
}

func writeKeyFile(dir, name, content string) string {
path := filepath.Join(dir, name)
err := os.WriteFile(path, []byte(content), 0o600)
Expect(err).NotTo(HaveOccurred())
return path
}

var _ = Describe("Config", func() {
var cmd *cobra.Command

Expand Down Expand Up @@ -328,6 +335,72 @@ ssh-authorized-keys:
Expect(cfg.SSHAuthorizedKeys).To(ContainElement("ssh-rsa YAMLKEY1"))
Expect(cfg.SSHAuthorizedKeys).To(ContainElement("ssh-ed25519 YAMLKEY2"))
})

It("should read public key from --ssh-key-file", func() {
tmpDir, err := os.MkdirTemp("", "virtwork-config-test-*")
Expect(err).NotTo(HaveOccurred())
defer func() {
_ = os.RemoveAll(tmpDir)
}()

keyPath := writeKeyFile(tmpDir, "id_ed25519.pub", "ssh-ed25519 AAAAC3filekey user@host\n")
err1 := cmd.Flags().Set("ssh-key-file", keyPath)
Expect(err1).NotTo(HaveOccurred())

cfg, err := config.LoadConfig(cmd)
Expect(err).NotTo(HaveOccurred())
Expect(cfg.SSHAuthorizedKeys).To(ContainElement("ssh-ed25519 AAAAC3filekey user@host"))
})

It("should read multiple --ssh-key-file flags", func() {
tmpDir, err := os.MkdirTemp("", "virtwork-config-test-*")
Expect(err).NotTo(HaveOccurred())
defer func() {
_ = os.RemoveAll(tmpDir)
}()

keyPath1 := writeKeyFile(tmpDir, "key1.pub", "ssh-rsa AAAA1 user1@host\n")
keyPath2 := writeKeyFile(tmpDir, "key2.pub", "ssh-ed25519 AAAA2 user2@host\n")
err1 := cmd.Flags().Set("ssh-key-file", keyPath1)
Expect(err1).NotTo(HaveOccurred())
err2 := cmd.Flags().Set("ssh-key-file", keyPath2)
Expect(err2).NotTo(HaveOccurred())

cfg, err := config.LoadConfig(cmd)
Expect(err).NotTo(HaveOccurred())
Expect(cfg.SSHAuthorizedKeys).To(HaveLen(2))
Expect(cfg.SSHAuthorizedKeys).To(ContainElement("ssh-rsa AAAA1 user1@host"))
Expect(cfg.SSHAuthorizedKeys).To(ContainElement("ssh-ed25519 AAAA2 user2@host"))
})

It("should merge --ssh-key and --ssh-key-file", func() {
tmpDir, err := os.MkdirTemp("", "virtwork-config-test-*")
Expect(err).NotTo(HaveOccurred())
defer func() {
_ = os.RemoveAll(tmpDir)
}()

keyPath := writeKeyFile(tmpDir, "id.pub", "ssh-ed25519 FILEkey user@host\n")
err1 := cmd.Flags().Set("ssh-key", "ssh-rsa INLINEkey")
Expect(err1).NotTo(HaveOccurred())
err2 := cmd.Flags().Set("ssh-key-file", keyPath)
Expect(err2).NotTo(HaveOccurred())

cfg, err := config.LoadConfig(cmd)
Expect(err).NotTo(HaveOccurred())
Expect(cfg.SSHAuthorizedKeys).To(HaveLen(2))
Expect(cfg.SSHAuthorizedKeys).To(ContainElement("ssh-rsa INLINEkey"))
Expect(cfg.SSHAuthorizedKeys).To(ContainElement("ssh-ed25519 FILEkey user@host"))
})

It("should return error for nonexistent --ssh-key-file", func() {
err1 := cmd.Flags().Set("ssh-key-file", "/nonexistent/key.pub")
Expect(err1).NotTo(HaveOccurred())

_, err := config.LoadConfig(cmd)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("/nonexistent/key.pub"))
})
})

Context("workload config", func() {
Expand Down
Loading