From 6e9438c73737a14ef2d29088ba0f329aae4d5adf Mon Sep 17 00:00:00 2001 From: Scott Walkinshaw Date: Sun, 18 Oct 2026 13:00:34 -0400 Subject: [PATCH] Add `trellis vault generate` command Generates passwords and WordPress keys/salts in group_vars//vault.yml for projects that weren't created with `trellis new` (e.g. cloned from a template repo). Fixes #426. Only missing, empty, or placeholder values ("generateme" and the example passwords in Trellis' default vaults) are generated, so it's idempotent. Comments and other values are preserved via yamledit, and Jinja templated values are never replaced. Encrypted vaults are decrypted and re-encrypted by piping through ansible-vault so plaintext never touches disk. --force regenerate all secrets (requires ENVIRONMENT) --salts-only only the WordPress keys and salts `trellis new` now uses the same code (with force) to update the downloaded vault.yml files in place instead of overwriting them, keeping Trellis' comments. It also no longer adds `admin_password` to non-development vaults since it's only used by the wordpress-install role in development. Co-Authored-By: Claude Opus 5.5 --- cmd/cmd_test.go | 4 + cmd/new.go | 17 +-- cmd/vault_generate.go | 230 ++++++++++++++++++++++++++++++ cmd/vault_generate_test.go | 177 +++++++++++++++++++++++ main.go | 3 + trellis/config.go | 8 +- trellis/trellis.go | 17 --- trellis/vault.go | 180 ++++++++++++++++------- trellis/vault_test.go | 284 ++++++++++++++++++++++++++++++++++--- 9 files changed, 820 insertions(+), 100 deletions(-) create mode 100644 cmd/vault_generate.go create mode 100644 cmd/vault_generate_test.go diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go index 9be09661..a04ee10a 100644 --- a/cmd/cmd_test.go +++ b/cmd/cmd_test.go @@ -28,3 +28,7 @@ func TestHelperProcess(t *testing.T) { fmt.Fprint(os.Stdout, strings.Join(os.Args[3:], " ")) os.Exit(0) } + +func TestCommandHelperProcess(t *testing.T) { + command.CommandHelperProcess(t) +} diff --git a/cmd/new.go b/cmd/new.go index 2219a2cb..5ac3ca57 100644 --- a/cmd/new.go +++ b/cmd/new.go @@ -167,13 +167,12 @@ func (c *NewCommand) Run(args []string) int { c.UI.Error("This is probably a trellis-cli bug. Please open an issue at: https://github.com/roots/trellis-cli") } - stringGenerator := trellis.RandomStringGenerator{Length: 64} - vault := c.trellis.GenerateVaultConfig(c.name, env, &stringGenerator) - _ = c.trellis.WriteYamlFile( - vault, - filepath.Join("group_vars", env, "vault.yml"), - c.YamlHeader("https://roots.io/trellis/docs/vault/"), - ) + vaultPath := filepath.Join("group_vars", env, "vault.yml") + if err := c.trellis.UpdateDefaultVaultFile(vaultPath, c.name, env, c.createdByComment()); err != nil { + c.UI.Error(fmt.Sprintf("Error updating %s: %s", vaultPath, err)) + c.UI.Error("Your project may not work correctly.") + c.UI.Error("This is probably a trellis-cli bug. Please open an issue at: https://github.com/roots/trellis-cli") + } } if err := c.trellis.GenerateVaultPassFile(c.vaultPass); err != nil { @@ -260,10 +259,6 @@ Options: return strings.TrimSpace(helpText) } -func (c *NewCommand) YamlHeader(doc string) string { - return fmt.Sprintf("%s\n# Documentation: %s\n\n", c.createdByComment(), doc) -} - func (c *NewCommand) createdByComment() string { return fmt.Sprintf("# Created by trellis-cli v%s", c.CliVersion) } diff --git a/cmd/vault_generate.go b/cmd/vault_generate.go new file mode 100644 index 00000000..85653702 --- /dev/null +++ b/cmd/vault_generate.go @@ -0,0 +1,230 @@ +package cmd + +import ( + "bytes" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/fatih/color" + "github.com/hashicorp/cli" + "github.com/posener/complete" + "github.com/roots/trellis-cli/command" + "github.com/roots/trellis-cli/trellis" + "github.com/roots/trellis-cli/trellis/yamledit" +) + +type VaultGenerateCommand struct { + UI cli.Ui + Trellis *trellis.Trellis + flags *flag.FlagSet + force bool + saltsOnly bool +} + +func NewVaultGenerateCommand(ui cli.Ui, trellis *trellis.Trellis) *VaultGenerateCommand { + c := &VaultGenerateCommand{UI: ui, Trellis: trellis} + c.init() + return c +} + +func (c *VaultGenerateCommand) init() { + c.flags = flag.NewFlagSet("", flag.ContinueOnError) + c.flags.Usage = func() { c.UI.Info(c.Help()) } + c.flags.BoolVar(&c.force, "force", false, "Regenerate secrets that are already set") + c.flags.BoolVar(&c.saltsOnly, "salts-only", false, "Only generate WordPress keys and salts") +} + +func (c *VaultGenerateCommand) Run(args []string) int { + if err := c.Trellis.LoadProject(); err != nil { + c.UI.Error(err.Error()) + return 1 + } + + if err := c.flags.Parse(args); err != nil { + return 1 + } + + args = c.flags.Args() + + commandArgumentValidator := &CommandArgumentValidator{required: 0, optional: 1} + commandArgumentErr := commandArgumentValidator.validate(args) + if commandArgumentErr != nil { + c.UI.Error(commandArgumentErr.Error()) + c.UI.Output(c.Help()) + return 1 + } + + environments := c.Trellis.EnvironmentNames() + + if len(args) == 1 { + if err := c.Trellis.ValidateEnvironment(args[0]); err != nil { + c.UI.Error(err.Error()) + return 1 + } + + environments = []string{args[0]} + } else if c.force { + c.UI.Error("Error: the --force option requires an ENVIRONMENT argument\n") + c.UI.Output(c.Help()) + return 1 + } + + opts := trellis.VaultGenerateOptions{Force: c.force, SaltsOnly: c.saltsOnly} + + for _, environment := range environments { + if err := c.generate(environment, opts); err != nil { + c.UI.Error(fmt.Sprintf("Error generating secrets for %s: %s", environment, err)) + return 1 + } + } + + return 0 +} + +func (c *VaultGenerateCommand) generate(environment string, opts trellis.VaultGenerateOptions) error { + path := filepath.Join("group_vars", environment, "vault.yml") + + data, encrypted, err := c.readVault(path) + if err != nil { + return err + } + + doc, err := yamledit.Parse(data) + if err != nil { + return err + } + + randomString := trellis.RandomStringGenerator{Length: 64} + sites := c.Trellis.SiteNamesFromEnvironment(environment) + + generated, err := trellis.GenerateVaultSecrets(doc, environment, sites, &randomString, opts) + if err != nil { + return err + } + + if len(generated) == 0 { + c.UI.Info(fmt.Sprintf("No secrets to generate in %s", path)) + return nil + } + + if encrypted { + data, err = doc.Bytes() + if err == nil { + err = c.encryptVault(path, data) + } + } else { + err = doc.Save(path) + } + + if err != nil { + return err + } + + c.UI.Info(color.GreenString(fmt.Sprintf("Generated %d secrets in %s", len(generated), path))) + + return nil +} + +// readVault returns the plaintext contents of a vault file, decrypting it +// with ansible-vault if needed. A missing file is treated as empty. +func (c *VaultGenerateCommand) readVault(path string) (data []byte, encrypted bool, err error) { + encrypted, err = trellis.IsFileEncrypted(path) + if os.IsNotExist(err) { + return nil, false, nil + } + + if err != nil { + return nil, false, err + } + + if !encrypted { + data, err = os.ReadFile(path) + return data, false, err + } + + c.Trellis.CheckVirtualenv(c.UI) + + var stdout, stderr bytes.Buffer + decrypt := command.Cmd("ansible-vault", []string{"decrypt", "--output", "-", path}) + decrypt.Stdout = &stdout + decrypt.Stderr = &stderr + + if err := decrypt.Run(); err != nil { + return nil, true, fmt.Errorf("could not decrypt %s: %s", path, strings.TrimSpace(stderr.String())) + } + + return stdout.Bytes(), true, nil +} + +// encryptVault encrypts data into path by piping it to ansible-vault so the +// plaintext never touches disk. +func (c *VaultGenerateCommand) encryptVault(path string, data []byte) error { + var stderr bytes.Buffer + encrypt := command.Cmd("ansible-vault", []string{"encrypt", "--output", path}) + encrypt.Stdin = bytes.NewReader(data) + encrypt.Stdout = &stderr + encrypt.Stderr = &stderr + + if err := encrypt.Run(); err != nil { + return fmt.Errorf("could not encrypt %s: %s", path, strings.TrimSpace(stderr.String())) + } + + return nil +} + +func (c *VaultGenerateCommand) Synopsis() string { + return "Generates passwords, keys, and salts in vault files" +} + +func (c *VaultGenerateCommand) Help() string { + helpText := ` +Usage: trellis vault generate [options] [ENVIRONMENT] + +Generates secrets (passwords and WordPress keys and salts) in group_vars//vault.yml files. + +Only secrets that are missing, empty, or placeholders (such as "generateme" or the example +passwords in Trellis' default vault files) are generated, so it's safe to run multiple times. +Templated values (containing "{{") are never replaced. + +Encrypted vault files are decrypted, updated, and re-encrypted with your project's vault password. + +Trellis docs: https://roots.io/trellis/docs/vault/ + +Generate missing secrets for all environments: + + $ trellis vault generate + +Generate missing secrets for production: + + $ trellis vault generate production + +Rotate WordPress keys and salts for production (logs out all users): + + $ trellis vault generate --force --salts-only production + +Arguments: + [ENVIRONMENT] Name of environment (ie: production) + +Options: + --force Regenerate all secrets, including ones already set. Requires ENVIRONMENT. + Changed database or user passwords take effect after re-provisioning the server. + --salts-only Only generate WordPress keys and salts + -h, --help Show this help +` + + return strings.TrimSpace(helpText) +} + +func (c *VaultGenerateCommand) AutocompleteArgs() complete.Predictor { + return c.Trellis.AutocompleteEnvironment(c.flags) +} + +func (c *VaultGenerateCommand) AutocompleteFlags() complete.Flags { + return complete.Flags{ + "--force": complete.PredictNothing, + "--salts-only": complete.PredictNothing, + } +} diff --git a/cmd/vault_generate_test.go b/cmd/vault_generate_test.go new file mode 100644 index 00000000..926dbac0 --- /dev/null +++ b/cmd/vault_generate_test.go @@ -0,0 +1,177 @@ +package cmd + +import ( + "os" + "strings" + "testing" + + "github.com/hashicorp/cli" + "github.com/roots/trellis-cli/command" + "github.com/roots/trellis-cli/trellis" + "github.com/roots/trellis-cli/trellis/yamledit" +) + +func TestVaultGenerateRunValidations(t *testing.T) { + defer trellis.LoadFixtureProject(t)() + + cases := []struct { + name string + projectDetected bool + args []string + out string + code int + }{ + { + "no_project", + false, + nil, + "No Trellis project detected", + 1, + }, + { + "too_many_args", + true, + []string{"production", "foo"}, + "Error: too many arguments", + 1, + }, + { + "invalid_env", + true, + []string{"foo"}, + "Error: foo is not a valid environment", + 1, + }, + { + "force_without_env", + true, + []string{"--force"}, + "Error: the --force option requires an ENVIRONMENT argument", + 1, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var project *trellis.Trellis + if tc.projectDetected { + project = trellis.NewTrellis() + } else { + project = trellis.NewMockTrellis(false) + } + + ui := cli.NewMockUi() + code := NewVaultGenerateCommand(ui, project).Run(tc.args) + + if code != tc.code { + t.Errorf("expected code %d to be %d", code, tc.code) + } + + combined := ui.OutputWriter.String() + ui.ErrorWriter.String() + + if !strings.Contains(combined, tc.out) { + t.Errorf("expected output %q to contain %q", combined, tc.out) + } + }) + } +} + +func TestVaultGenerateRun(t *testing.T) { + defer trellis.LoadFixtureProject(t)() + + ui := cli.NewMockUi() + code := NewVaultGenerateCommand(ui, trellis.NewTrellis()).Run([]string{}) + + if code != 0 { + t.Fatalf("expected code 0, got %d: %s", code, ui.ErrorWriter.String()) + } + + output := ui.OutputWriter.String() + + for _, expected := range []string{ + "Generated 10 secrets in group_vars/development/vault.yml", + "Generated 12 secrets in group_vars/production/vault.yml", + "Generated 39 secrets in group_vars/valet-link/vault.yml", + } { + if !strings.Contains(output, expected) { + t.Errorf("expected output %q to contain %q", output, expected) + } + } + + doc, err := yamledit.Load("group_vars/production/vault.yml") + if err != nil { + t.Fatal(err) + } + + if node := doc.Lookup("vault_wordpress_sites", "example.com", "env", "auth_salt"); node == nil || len(node.Value) != 64 { + t.Errorf("expected auth_salt to be generated, got %v", node) + } + + data, _ := os.ReadFile("group_vars/production/vault.yml") + if !strings.Contains(string(data), "# Generate your keys here: https://roots.io/salts.html") { + t.Errorf("expected comments to be preserved, got:\n%s", data) + } + + ui = cli.NewMockUi() + NewVaultGenerateCommand(ui, trellis.NewTrellis()).Run([]string{"production"}) + + if !strings.Contains(ui.OutputWriter.String(), "No secrets to generate in group_vars/production/vault.yml") { + t.Errorf("expected second run to generate nothing, got %q", ui.OutputWriter.String()) + } +} + +func TestVaultGenerateRunEncrypted(t *testing.T) { + defer trellis.LoadFixtureProject(t)() + + path := "group_vars/production/vault.yml" + plaintext, _ := os.ReadFile(path) + + if err := os.WriteFile(path, []byte("$ANSIBLE_VAULT;1.1;AES256\nencrypted\n"), 0644); err != nil { + t.Fatal(err) + } + + defer command.MockExecCommands(t, []command.MockCommand{ + {Command: "ansible-vault", Args: []string{"decrypt", "--output", "-", path}, Output: string(plaintext)}, + {Command: "ansible-vault", Args: []string{"encrypt", "--output", path}}, + })() + + ui := cli.NewMockUi() + code := NewVaultGenerateCommand(ui, trellis.NewTrellis()).Run([]string{"production"}) + + if code != 0 { + t.Fatalf("expected code 0, got %d: %s", code, ui.ErrorWriter.String()) + } + + if !strings.Contains(ui.OutputWriter.String(), "Generated 12 secrets in "+path) { + t.Errorf("unexpected output %q", ui.OutputWriter.String()) + } + + data, _ := os.ReadFile(path) + if !strings.HasPrefix(string(data), "$ANSIBLE_VAULT") { + t.Errorf("expected vault file to never be written in plaintext, got:\n%s", data) + } +} + +func TestVaultGenerateRunDecryptFailure(t *testing.T) { + defer trellis.LoadFixtureProject(t)() + + path := "group_vars/production/vault.yml" + if err := os.WriteFile(path, []byte("$ANSIBLE_VAULT;1.1;AES256\nencrypted\n"), 0644); err != nil { + t.Fatal(err) + } + + defer command.MockExecCommands(t, []command.MockCommand{ + {Command: "ansible-vault", Args: []string{"decrypt", "--output", "-", path}, ExitCode: 1}, + })() + + ui := cli.NewMockUi() + code := NewVaultGenerateCommand(ui, trellis.NewTrellis()).Run([]string{"production"}) + + if code != 1 { + t.Errorf("expected code 1, got %d", code) + } + + if !strings.Contains(ui.ErrorWriter.String(), "could not decrypt "+path) { + t.Errorf("unexpected error output %q", ui.ErrorWriter.String()) + } +} diff --git a/main.go b/main.go index 7c187401..e9c34f85 100644 --- a/main.go +++ b/main.go @@ -164,6 +164,9 @@ func main() { "vault encrypt": func() (cli.Command, error) { return cmd.NewVaultEncryptCommand(ui, trellis), nil }, + "vault generate": func() (cli.Command, error) { + return cmd.NewVaultGenerateCommand(ui, trellis), nil + }, "vault decrypt": func() (cli.Command, error) { return cmd.NewVaultDecryptCommand(ui, trellis), nil }, diff --git a/trellis/config.go b/trellis/config.go index e1a28455..79ee0904 100644 --- a/trellis/config.go +++ b/trellis/config.go @@ -150,11 +150,15 @@ func (t *Trellis) UpdateDefaultSiteFile(path string, name string, host string, e } } + prependHeadComment(doc, header) + + return doc.Save(path) +} + +func prependHeadComment(doc *yamledit.Document, header string) { if header != "" { doc.SetHeadComment(strings.TrimRight(header+"\n"+doc.HeadComment(), "\n")) } - - return doc.Save(path) } func (s *Site) SslEnabled() bool { diff --git a/trellis/trellis.go b/trellis/trellis.go index aa93894d..2ce8ea5b 100644 --- a/trellis/trellis.go +++ b/trellis/trellis.go @@ -14,7 +14,6 @@ import ( "github.com/roots/trellis-cli/app_paths" "github.com/roots/trellis-cli/cli_config" "gopkg.in/ini.v1" - "gopkg.in/yaml.v2" ) const ( @@ -368,22 +367,6 @@ func (t *Trellis) UpdateAnsibleConfig(section string, key string, value string) return nil } -func (t *Trellis) WriteYamlFile(s any, path string, header string) error { - data, err := yaml.Marshal(s) - if err != nil { - log.Fatal(err) - } - - path = filepath.Join(t.Path, path) - data = append([]byte(header), data...) - - if err := os.WriteFile(path, data, 0666); err != nil { - log.Fatal(err) - } - - return nil -} - func (t *Trellis) VmManagerType() string { switch t.CliConfig.Vm.Manager { case "auto": diff --git a/trellis/vault.go b/trellis/vault.go index 9ea612ea..51ef1f5b 100644 --- a/trellis/vault.go +++ b/trellis/vault.go @@ -7,7 +7,12 @@ import ( "log" "os" "path/filepath" + "slices" + "strconv" "strings" + + "github.com/roots/trellis-cli/trellis/yamledit" + "go.yaml.in/yaml/v3" ) const fileHeader = "$ANSIBLE_VAULT;1.1;AES256" @@ -47,69 +52,146 @@ func (rs *RandomStringGenerator) Generate() string { return generateRandomString(rs.Length) } -type Vault struct { - MysqlRootPassword string `yaml:"vault_mysql_root_password"` - Users []VaultUser `yaml:"vault_users,omitempty"` - WordPressSites map[string]VaultWordPressSite `yaml:"vault_wordpress_sites"` +// VaultPlaceholders are values treated as unset secrets: empty values and the +// examples shipped in Trellis' default vault files. +var VaultPlaceholders = []string{ + "", + "generateme", + "devpw", + "stagingpw", + "productionpw", + "example_password", + "example_dbpassword", } -type VaultUser struct { - Name string `yaml:"name"` - Password string `yaml:"password"` - Salt string `yaml:"salt"` +var wordpressSaltKeys = []string{ + "auth_key", + "secure_auth_key", + "logged_in_key", + "nonce_key", + "auth_salt", + "secure_auth_salt", + "logged_in_salt", + "nonce_salt", } -type VaultWordPressSite struct { - AdminPassword string `yaml:"admin_password"` - Env VaultWordPressSiteEnv `yaml:"env"` +type VaultGenerateOptions struct { + // Force regenerates secrets that are already set, not just placeholders. + Force bool + // SaltsOnly limits generation to the WordPress keys and salts. + SaltsOnly bool } -type VaultWordPressSiteEnv struct { - DbPassword string `yaml:"db_password"` - AuthKey string `yaml:"auth_key,omitempty"` - SecureAuthKey string `yaml:"secure_auth_key,omitempty"` - LoggedInKey string `yaml:"logged_in_key,omitempty"` - NonceKey string `yaml:"nonce_key,omitempty"` - AuthSalt string `yaml:"auth_salt,omitempty"` - SecureAuthSalt string `yaml:"secure_auth_salt,omitempty"` - LoggedInSalt string `yaml:"logged_in_salt,omitempty"` - NonceSalt string `yaml:"nonce_salt,omitempty"` +// GenerateVaultSecrets sets random values for the secrets of an environment's +// vault.yml document, adding any that are missing. Existing values are kept +// unless they are placeholders or opts.Force is set; Jinja templated values are +// never replaced. It returns the paths of the generated values. +func GenerateVaultSecrets(doc *yamledit.Document, env string, sites []string, randomString StringGenerator, opts VaultGenerateOptions) ([][]string, error) { + assertAvailablePRNG() + + g := &vaultGenerator{doc: doc, randomString: randomString, force: opts.Force} + + if !opts.SaltsOnly { + g.generate("vault_mysql_root_password") + + if env != "development" { + g.generateUsers() + } + } + + for _, site := range sites { + if !opts.SaltsOnly { + // Only used by the wordpress-install role in development + if env == "development" { + g.generate("vault_wordpress_sites", site, "admin_password") + } + + g.generate("vault_wordpress_sites", site, "env", "db_password") + } + + for _, key := range wordpressSaltKeys { + g.generate("vault_wordpress_sites", site, "env", key) + } + } + + return g.generated, g.err } -func (t *Trellis) GenerateVaultConfig(name string, env string, randomString StringGenerator) *Vault { - assertAvailablePRNG() - var siteEnv VaultWordPressSiteEnv - - vault := Vault{MysqlRootPassword: randomString.Generate()} - siteEnv = VaultWordPressSiteEnv{ - DbPassword: randomString.Generate(), - AuthKey: randomString.Generate(), - SecureAuthKey: randomString.Generate(), - LoggedInKey: randomString.Generate(), - NonceKey: randomString.Generate(), - AuthSalt: randomString.Generate(), - SecureAuthSalt: randomString.Generate(), - LoggedInSalt: randomString.Generate(), - NonceSalt: randomString.Generate(), - } - - if env != "development" { - user := VaultUser{ - Name: "{{ admin_user }}", - Password: randomString.Generate(), - Salt: randomString.Generate(), +// UpdateDefaultVaultFile renames the default site in a new project's vault.yml +// file (relative to the project) and generates all of its secrets. +func (t *Trellis) UpdateDefaultVaultFile(path string, name string, env string, header string) error { + path = filepath.Join(t.Path, path) + + doc, err := yamledit.Load(path) + if os.IsNotExist(err) { + doc, err = yamledit.Parse(nil) + } + + if err != nil { + return err + } + + if doc.Lookup("vault_wordpress_sites", DefaultSiteName) != nil { + if err := doc.RenameKey(name, "vault_wordpress_sites", DefaultSiteName); err != nil { + return err } + } - vault.Users = []VaultUser{user} + randomString := RandomStringGenerator{Length: 64} + if _, err := GenerateVaultSecrets(doc, env, []string{name}, &randomString, VaultGenerateOptions{Force: true}); err != nil { + return err } - vault.WordPressSites = make(map[string]VaultWordPressSite) - vault.WordPressSites[name] = VaultWordPressSite{ - AdminPassword: randomString.Generate(), - Env: siteEnv, + prependHeadComment(doc, header) + + return doc.Save(path) +} + +type vaultGenerator struct { + doc *yamledit.Document + randomString StringGenerator + force bool + generated [][]string + err error +} + +func (g *vaultGenerator) generate(path ...string) { + if g.err != nil { + return } - return &vault + if node := g.doc.Lookup(path...); node != nil { + if node.Kind != yaml.ScalarNode || strings.Contains(node.Value, "{{") { + return + } + + if !g.force && !slices.Contains(VaultPlaceholders, node.Value) { + return + } + } + + if g.err = g.doc.Set(g.randomString.Generate(), path...); g.err == nil { + g.generated = append(g.generated, path) + } +} + +func (g *vaultGenerator) generateUsers() { + users := g.doc.Lookup("vault_users") + + if users == nil { + g.err = g.doc.Set([]map[string]string{{"name": "{{ admin_user }}"}}, "vault_users") + users = g.doc.Lookup("vault_users") + } + + if g.err != nil || users.Kind != yaml.SequenceNode { + return + } + + for i := range users.Content { + index := strconv.Itoa(i) + g.generate("vault_users", index, "password") + g.generate("vault_users", index, "salt") + } } func (t *Trellis) GenerateVaultPassFile(path string) error { diff --git a/trellis/vault_test.go b/trellis/vault_test.go index 95f5eb04..73b00984 100644 --- a/trellis/vault_test.go +++ b/trellis/vault_test.go @@ -1,9 +1,12 @@ package trellis import ( - "gopkg.in/yaml.v2" + "os" + "path/filepath" "strings" "testing" + + "github.com/roots/trellis-cli/trellis/yamledit" ) type MockStringGenerator struct{} @@ -12,20 +15,45 @@ func (ms *MockStringGenerator) Generate() string { return "random" } -func TestGenerateVaultConfig(t *testing.T) { - trellis := &Trellis{} - mockStringGenerator := &MockStringGenerator{} +const productionVault = `# Documentation: https://roots.io/trellis/docs/vault/ +vault_mysql_root_password: productionpw + +# Documentation: https://roots.io/trellis/docs/security/ +vault_users: + - name: "{{ admin_user }}" + password: example_password + salt: "generateme" +vault_wordpress_sites: + example.com: + env: + db_password: my_real_password + # Generate your keys here: https://roots.io/salts.html + auth_key: "generateme" + secure_auth_key: "generateme" + logged_in_key: "generateme" + nonce_key: "generateme" + auth_salt: "generateme" + secure_auth_salt: "generateme" + logged_in_salt: "generateme" + nonce_salt: "" +` + +func TestGenerateVaultSecrets(t *testing.T) { cases := []struct { - name string - siteName string - env string - expectedYaml string + name string + input string + env string + sites []string + opts VaultGenerateOptions + expected string }{ { - "development_env", - "testsite.com", + "empty_development", + "", "development", + []string{"testsite.com"}, + VaultGenerateOptions{}, ` vault_mysql_root_password: random vault_wordpress_sites: @@ -44,17 +72,110 @@ vault_wordpress_sites: `, }, { - "production_env", - "testsite.com", + "empty_production", + "", "production", + []string{"testsite.com"}, + VaultGenerateOptions{}, ` vault_mysql_root_password: random vault_users: -- name: '{{ admin_user }}' - password: random - salt: random + - name: '{{ admin_user }}' + password: random + salt: random vault_wordpress_sites: testsite.com: + env: + db_password: random + auth_key: random + secure_auth_key: random + logged_in_key: random + nonce_key: random + auth_salt: random + secure_auth_salt: random + logged_in_salt: random + nonce_salt: random +`, + }, + { + "fills_placeholders_only", + productionVault, + "production", + []string{"example.com"}, + VaultGenerateOptions{}, + ` +# Documentation: https://roots.io/trellis/docs/vault/ +vault_mysql_root_password: random + +# Documentation: https://roots.io/trellis/docs/security/ +vault_users: + - name: "{{ admin_user }}" + password: random + salt: "random" + +vault_wordpress_sites: + example.com: + env: + db_password: my_real_password + # Generate your keys here: https://roots.io/salts.html + auth_key: "random" + secure_auth_key: "random" + logged_in_key: "random" + nonce_key: "random" + auth_salt: "random" + secure_auth_salt: "random" + logged_in_salt: "random" + nonce_salt: "random" +`, + }, + { + "salts_only", + productionVault, + "production", + []string{"example.com"}, + VaultGenerateOptions{SaltsOnly: true}, + ` +# Documentation: https://roots.io/trellis/docs/vault/ +vault_mysql_root_password: productionpw + +# Documentation: https://roots.io/trellis/docs/security/ +vault_users: + - name: "{{ admin_user }}" + password: example_password + salt: "generateme" + +vault_wordpress_sites: + example.com: + env: + db_password: my_real_password + # Generate your keys here: https://roots.io/salts.html + auth_key: "random" + secure_auth_key: "random" + logged_in_key: "random" + nonce_key: "random" + auth_salt: "random" + secure_auth_salt: "random" + logged_in_salt: "random" + nonce_salt: "random" +`, + }, + { + "force_replaces_set_values_but_not_templates", + ` +vault_mysql_root_password: "{{ lookup('env', 'MYSQL_ROOT') }}" +vault_wordpress_sites: + example.com: + admin_password: secret + env: + db_password: my_real_password +`, + "development", + []string{"example.com"}, + VaultGenerateOptions{Force: true, SaltsOnly: false}, + ` +vault_mysql_root_password: "{{ lookup('env', 'MYSQL_ROOT') }}" +vault_wordpress_sites: + example.com: admin_password: random env: db_password: random @@ -66,19 +187,140 @@ vault_wordpress_sites: secure_auth_salt: random logged_in_salt: random nonce_salt: random +`, + }, + { + "multiple_sites_and_non_sequence_users", + ` +vault_mysql_root_password: set +vault_users: "{{ my_users }}" +`, + "production", + []string{"a.com", "b.com"}, + VaultGenerateOptions{SaltsOnly: true}, + ` +vault_mysql_root_password: set +vault_users: "{{ my_users }}" +vault_wordpress_sites: + a.com: + env: + auth_key: random + secure_auth_key: random + logged_in_key: random + nonce_key: random + auth_salt: random + secure_auth_salt: random + logged_in_salt: random + nonce_salt: random + b.com: + env: + auth_key: random + secure_auth_key: random + logged_in_key: random + nonce_key: random + auth_salt: random + secure_auth_salt: random + logged_in_salt: random + nonce_salt: random `, }, } for _, tc := range cases { - vault := trellis.GenerateVaultConfig(tc.siteName, tc.env, mockStringGenerator) + t.Run(tc.name, func(t *testing.T) { + doc, err := yamledit.Parse([]byte(strings.TrimLeft(tc.input, "\n"))) + if err != nil { + t.Fatal(err) + } + + if _, err := GenerateVaultSecrets(doc, tc.env, tc.sites, &MockStringGenerator{}, tc.opts); err != nil { + t.Fatal(err) + } - vaultYaml, _ := yaml.Marshal(vault) - expected := strings.TrimSpace(tc.expectedYaml) - output := strings.TrimSpace(string(vaultYaml)) + output, _ := doc.Bytes() + expected := strings.TrimSpace(tc.expected) + + if strings.TrimSpace(string(output)) != expected { + t.Errorf("expected:\n%s\ngot:\n%s", expected, output) + } + }) + } +} + +func TestGenerateVaultSecretsReturnsGeneratedPaths(t *testing.T) { + doc, _ := yamledit.Parse([]byte(productionVault)) + + generated, err := GenerateVaultSecrets(doc, "production", []string{"example.com"}, &MockStringGenerator{}, VaultGenerateOptions{}) + if err != nil { + t.Fatal(err) + } - if output != expected { - t.Errorf("expected yaml output %s but got %s", expected, output) + // root password, user password + salt, 8 keys/salts (db_password is already set) + if len(generated) != 11 { + t.Errorf("expected 11 generated values, got %d: %v", len(generated), generated) + } + + generated, _ = GenerateVaultSecrets(doc, "production", []string{"example.com"}, &MockStringGenerator{}, VaultGenerateOptions{}) + if len(generated) != 0 { + t.Errorf("expected second run to generate nothing, got %v", generated) + } +} + +func TestUpdateDefaultVaultFile(t *testing.T) { + trellis := &Trellis{Path: t.TempDir()} + + data, _ := os.ReadFile("testdata/trellis/group_vars/development/vault.yml") + if err := os.WriteFile(filepath.Join(trellis.Path, "vault.yml"), data, 0644); err != nil { + t.Fatal(err) + } + + if err := trellis.UpdateDefaultVaultFile("vault.yml", "mysite.com", "development", "# Created by trellis-cli v1.0.0"); err != nil { + t.Fatal(err) + } + + doc, err := yamledit.Load(filepath.Join(trellis.Path, "vault.yml")) + if err != nil { + t.Fatal(err) + } + + if doc.Lookup("vault_wordpress_sites", DefaultSiteName) != nil { + t.Error("expected default site to be renamed") + } + + for _, path := range [][]string{ + {"vault_mysql_root_password"}, + {"vault_wordpress_sites", "mysite.com", "admin_password"}, + {"vault_wordpress_sites", "mysite.com", "env", "db_password"}, + {"vault_wordpress_sites", "mysite.com", "env", "nonce_salt"}, + } { + if node := doc.Lookup(path...); node == nil || len(node.Value) != 64 { + t.Errorf("expected %v to be a generated 64 character value, got %v", path, node) } } + + if !strings.HasPrefix(doc.HeadComment(), "# Created by trellis-cli v1.0.0") { + t.Errorf("expected header comment, got %q", doc.HeadComment()) + } + + output, _ := os.ReadFile(filepath.Join(trellis.Path, "vault.yml")) + if !strings.Contains(string(output), "# Documentation: https://roots.io/trellis/docs/vault/") { + t.Errorf("expected original comments to be preserved, got:\n%s", output) + } +} + +func TestUpdateDefaultVaultFileMissing(t *testing.T) { + trellis := &Trellis{Path: t.TempDir()} + + if err := trellis.UpdateDefaultVaultFile("vault.yml", "mysite.com", "production", ""); err != nil { + t.Fatal(err) + } + + doc, err := yamledit.Load(filepath.Join(trellis.Path, "vault.yml")) + if err != nil { + t.Fatal(err) + } + + if node := doc.Lookup("vault_users", "0", "salt"); node == nil || len(node.Value) != 64 { + t.Errorf("expected vault_users to be generated, got %v", node) + } }