Skip to content

Commit 32bf70a

Browse files
tantovanwijheclaude
andcommitted
Render ERB in database.yml so vmt restore resolves the target database
vmt parsed database.yml as plain YAML, so a Rails database name written as `<%= ENV.fetch("PGDATABASE") { "myapp-dev" } %>` was handed to Postgres verbatim and the restore failed with a confusing "couldn't connect". Evaluate the env-reading ERB these files actually use before parsing: ENV["X"], ENV.fetch("X") { default }, ENV.fetch("X", default), and the common ENV["X"] || "default" fallback form — honouring environment variables and their defaults exactly as Rails does. Output tags that aren't recognised are left untouched and reported with a warning, so an unsupported construct surfaces clearly instead of as a cryptic connection error later. Files without ERB (non-Ruby projects) are unaffected, and there's no new runtime dependency. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 3b2653a commit 32bf70a

6 files changed

Lines changed: 185 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
# v2.1.0
2+
3+
* **`vmt restore` now reads Rails `database.yml` files that use ERB.** Database names written as `<%= ENV.fetch("PGDATABASE") { "myapp-dev" } %>` previously caused a confusing "Couldn't connect to the target database" failure, because vmt fed the raw template text to Postgres as the database name. vmt now renders the env-reading expressions these files use — `<%= ENV["X"] %>`, `<%= ENV.fetch("X") { "default" } %>`, `<%= ENV.fetch("X", "default") %>`, and the `<%= ENV["X"] || "default" %>` fallback form — honouring environment variables and their defaults exactly as Rails does, before parsing the YAML. Any output tag it can't evaluate (e.g. `<%= Rails.application.credentials… %>`) is left untouched and reported with a warning, so an unsupported construct surfaces clearly instead of as a cryptic connection error. Config files without ERB (non-Ruby projects) are unaffected, and there's no new runtime dependency. Pass `--database` to override the file as before.
4+
15
# v2.0.1
26

37
* **CI release pipeline now produces downloadable binaries again.** The previous releases (v1.4.3, v1.5.0, v2.0.0) had their tags pushed but the release workflow failed before it could attach any artifacts, so their GitHub release pages are empty. This release is functionally identical to v2.0.0 — install it to get the v2.0.0 features with working install scripts.

pkg/backup/backup.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ func Run(log *util.Logger, port string, host string, sourceShard string, b2id st
8282
return err
8383
}
8484

85-
target, err := util.GetDatabaseConfig(databaseSelection.Database, "custom", sourceShard, response.User, "", host, port, configFile)
85+
target, err := util.GetDatabaseConfig(log, databaseSelection.Database, "custom", sourceShard, response.User, "", host, port, configFile)
8686
if err != nil {
8787
return err
8888
}

pkg/restore/restore.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ func Run(log *util.Logger, targetEnvironment string, targetShard string, b2id st
117117

118118
selectedBackup := displayToBackup[backupSelection.Backup]
119119

120-
target, err := util.GetDatabaseConfig(targetDatabase, targetEnvironment, targetShard, targetUsername, targetPassword, targetHost, targetPort, configFile)
120+
target, err := util.GetDatabaseConfig(log, targetDatabase, targetEnvironment, targetShard, targetUsername, targetPassword, targetHost, targetPort, configFile)
121121
if err != nil {
122122
return err
123123
}

pkg/util/dbconf.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package util
22

33
import (
4+
"fmt"
45
"io/ioutil"
56

67
"github.com/pkg/errors"
@@ -34,17 +35,25 @@ type ShardedDatabaseConfig struct {
3435
}
3536

3637
// GetDatabaseConfig based on provided arguments
37-
func GetDatabaseConfig(database string, environment string, shard string, user string, password string, host string, port string, configFile string) (TargetConfig, error) {
38+
func GetDatabaseConfig(log *Logger, database string, environment string, shard string, user string, password string, host string, port string, configFile string) (TargetConfig, error) {
3839
target := TargetConfig{}
3940
if database == "" {
4041
yamlFile, err := ioutil.ReadFile(configFile)
4142
if err != nil {
4243
return target, err
4344
}
4445

46+
// database.yml is an ERB template in Rails projects; render it so values
47+
// like `<%= ENV.fetch("PGDATABASE") { "myapp-dev" } %>` resolve before parsing.
48+
renderedStr, unresolved := renderERB(string(yamlFile))
49+
rendered := []byte(renderedStr)
50+
for _, tag := range unresolved {
51+
log.Warn(fmt.Sprintf("Could not evaluate ERB in %s: %s — leaving it unrendered (only ENV[...] and ENV.fetch(...) are supported)", configFile, tag))
52+
}
53+
4554
if shard != "" {
4655
dbConfig := ShardedDatabaseConfig{}
47-
err = yaml.Unmarshal(yamlFile, &dbConfig)
56+
err = yaml.Unmarshal(rendered, &dbConfig)
4857
if err != nil {
4958
return target, err
5059
}
@@ -68,7 +77,7 @@ func GetDatabaseConfig(database string, environment string, shard string, user s
6877
}
6978
} else {
7079
dbConfig := DatabaseConfig{}
71-
err = yaml.Unmarshal(yamlFile, &dbConfig)
80+
err = yaml.Unmarshal(rendered, &dbConfig)
7281
if err != nil {
7382
return target, err
7483
}

pkg/util/erb.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package util
2+
3+
import (
4+
"os"
5+
"regexp"
6+
"strings"
7+
)
8+
9+
// Rails database.yml files are ERB templates: values like the database name are
10+
// commonly written as `<%= ENV.fetch("PGDATABASE") { "myapp-dev" } %>`. Rails
11+
// renders these before parsing the YAML; a plain YAML parser sees the raw tag
12+
// instead and tries to connect to a database literally named `<%= ... %>`.
13+
//
14+
// vmt has no Ruby runtime to lean on, so renderERB evaluates the small subset of
15+
// ERB that database.yml files actually use: reading environment variables, with
16+
// an optional default. Files without ERB tags (e.g. non-Ruby projects) pass
17+
// through untouched.
18+
19+
// erbOutputRe matches an ERB output tag `<%= expr %>`, tolerating the
20+
// whitespace-trim markers Rails permits (`<%=- ... -%>`).
21+
var erbOutputRe = regexp.MustCompile(`(?s)<%=-?\s*(.*?)\s*-?%>`)
22+
23+
// erbOtherRe matches non-output tags — comments `<%# ... %>` and bare code
24+
// `<% ... %>` — which produce no output in Rails and are stripped here.
25+
var erbOtherRe = regexp.MustCompile(`(?s)<%[^=].*?%>|<%%>`)
26+
27+
// envFetchRe matches `ENV.fetch("NAME")` with an optional default supplied
28+
// either as a second argument (`, "default"`) or a block (`{ "default" }`).
29+
var envFetchRe = regexp.MustCompile(`^ENV\.fetch\(\s*['"]([^'"]+)['"]\s*(?:,\s*(.+?)\s*)?\)\s*(?:\{\s*(.+?)\s*\})?$`)
30+
31+
// envIndexRe matches `ENV["NAME"]`, optionally with a `|| "default"` fallback —
32+
// `<%= ENV["DB_HOST"] || "localhost" %>` is as common as the fetch form.
33+
var envIndexRe = regexp.MustCompile(`^ENV\[\s*['"]([^'"]+)['"]\s*\]\s*(?:\|\|\s*(.+?)\s*)?$`)
34+
35+
// renderERB evaluates the ENV-reading ERB expressions in a database.yml file and
36+
// returns the rendered YAML alongside any output tags it could not evaluate. An
37+
// unrecognised tag is left verbatim, but since that verbatim text is itself valid
38+
// YAML it would not fail at parse time — it would silently become the value and
39+
// resurface later as an opaque connection error. The caller is expected to warn
40+
// about the returned tags so the limitation is visible.
41+
func renderERB(content string) (string, []string) {
42+
var unresolved []string
43+
44+
out := erbOutputRe.ReplaceAllStringFunc(content, func(tag string) string {
45+
expr := erbOutputRe.FindStringSubmatch(tag)[1]
46+
if value, ok := evalEnvExpr(expr); ok {
47+
return value
48+
}
49+
unresolved = append(unresolved, tag)
50+
return tag
51+
})
52+
53+
return erbOtherRe.ReplaceAllString(out, ""), unresolved
54+
}
55+
56+
// evalEnvExpr evaluates a single ERB expression. The bool result reports whether
57+
// the expression was recognised; an unrecognised expression is left untouched.
58+
func evalEnvExpr(expr string) (string, bool) {
59+
if m := envIndexRe.FindStringSubmatch(expr); m != nil {
60+
if value, ok := os.LookupEnv(m[1]); ok {
61+
return value, true
62+
}
63+
// Key absent: Ruby's `ENV["X"]` is nil, so `|| default` applies. When
64+
// there's no fallback, m[2] is empty and unquote yields "".
65+
return unquote(m[2]), true
66+
}
67+
68+
if m := envFetchRe.FindStringSubmatch(expr); m != nil {
69+
if value, ok := os.LookupEnv(m[1]); ok {
70+
return value, true
71+
}
72+
if m[3] != "" { // block default: ENV.fetch("X") { "default" }
73+
return unquote(m[3]), true
74+
}
75+
if m[2] != "" { // argument default: ENV.fetch("X", "default")
76+
return unquote(m[2]), true
77+
}
78+
return "", true
79+
}
80+
81+
return "", false
82+
}
83+
84+
// unquote strips a single matched pair of surrounding quotes, leaving bare
85+
// literals (numbers, booleans) as-is. Ruby's `""` default becomes an empty string.
86+
func unquote(s string) string {
87+
s = strings.TrimSpace(s)
88+
if len(s) >= 2 {
89+
first, last := s[0], s[len(s)-1]
90+
if (first == '"' && last == '"') || (first == '\'' && last == '\'') {
91+
return s[1 : len(s)-1]
92+
}
93+
}
94+
return s
95+
}

pkg/util/erb_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package util
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
func TestRenderERB(t *testing.T) {
10+
t.Setenv("PGDATABASE", "noticehub-app-dev")
11+
t.Setenv("PGHOST", "db.internal")
12+
// PGUSER and PGPASSWORD are intentionally left unset to exercise defaults.
13+
14+
cases := []struct {
15+
name string
16+
in string
17+
out string
18+
}{
19+
{"fetch with block default, env set", `<%= ENV.fetch("PGDATABASE") { "fallback" } %>`, "noticehub-app-dev"},
20+
{"fetch with block default, env unset", `<%= ENV.fetch("PGUSER") { "localhost" } %>`, "localhost"},
21+
{"fetch with empty block default", `<%= ENV.fetch("PGPASSWORD") { "" } %>`, ""},
22+
{"fetch with bare integer default", `<%= ENV.fetch("RAILS_MAX_THREADS") { 10 } %>`, "10"},
23+
{"fetch with argument default", `<%= ENV.fetch("PGUSER", "postgres") %>`, "postgres"},
24+
{"fetch without default, env unset", `<%= ENV.fetch("PGUSER") %>`, ""},
25+
{"index, env set", `<%= ENV["PGHOST"] %>`, "db.internal"},
26+
{"index, env unset", `<%= ENV["MISSING"] %>`, ""},
27+
{"index with || fallback, env set", `<%= ENV["PGHOST"] || "localhost" %>`, "db.internal"},
28+
{"index with || fallback, env unset", `<%= ENV["MISSING"] || "localhost" %>`, "localhost"},
29+
{"index with || fallback, single quotes", `<%= ENV['MISSING'] || 'fallback' %>`, "fallback"},
30+
{"single quotes", `<%= ENV.fetch('PGHOST') { 'x' } %>`, "db.internal"},
31+
{"trim markers", `<%=- ENV.fetch("PGHOST") { "x" } -%>`, "db.internal"},
32+
{"comment tag stripped", `before<%# secret %>after`, "beforeafter"},
33+
{"no erb passes through", "database: plain-name", "database: plain-name"},
34+
{"unrecognised expr left verbatim", `<%= Rails.env %>`, `<%= Rails.env %>`},
35+
}
36+
37+
for _, tc := range cases {
38+
t.Run(tc.name, func(t *testing.T) {
39+
out, _ := renderERB(tc.in)
40+
assert.Equal(t, tc.out, out)
41+
})
42+
}
43+
}
44+
45+
func TestRenderERBReportsUnresolved(t *testing.T) {
46+
t.Setenv("PGHOST", "db.internal")
47+
48+
out, unresolved := renderERB(`a: <%= ENV["PGHOST"] %>
49+
b: <%= Rails.application.credentials.dig(:db) %>
50+
c: <%= ENV["MISSING"] || "ok" %>`)
51+
52+
assert.Equal(t, "a: db.internal\nb: <%= Rails.application.credentials.dig(:db) %>\nc: ok", out)
53+
assert.Equal(t, []string{`<%= Rails.application.credentials.dig(:db) %>`}, unresolved)
54+
}
55+
56+
func TestRenderERBFullConfig(t *testing.T) {
57+
t.Setenv("PGDATABASE", "noticehub-app-dev")
58+
59+
in := `development:
60+
host: "localhost"
61+
database: <%= ENV.fetch("PGDATABASE") { "noticehub-app-dev" } %>
62+
username: <%= ENV.fetch("PGUSER") { "" } %>
63+
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 10 } %>
64+
`
65+
out := "development:\n" +
66+
" host: \"localhost\"\n" +
67+
" database: noticehub-app-dev\n" +
68+
" username: \n" + // empty value leaves the trailing space; YAML reads it as nil
69+
" pool: 10\n"
70+
rendered, _ := renderERB(in)
71+
assert.Equal(t, out, rendered)
72+
}

0 commit comments

Comments
 (0)