1 package configx
2
3 import (
4 "bytes"
5 "context"
6 "fmt"
7 "io"
8 "io/ioutil"
9 "os"
10 "os/exec"
11 "runtime"
12 "strconv"
13 "strings"
14 "testing"
15 "time"
16
17 "github.com/sirupsen/logrus"
18 "github.com/sirupsen/logrus/hooks/test"
19 "github.com/stretchr/testify/assert"
20 "github.com/stretchr/testify/require"
21
22 "github.com/ory/x/logrusx"
23 "github.com/ory/x/watcherx"
24 )
25
26 func tmpConfigFile(t *testing.T, dsn, foo string) *os.File {
27 config := fmt.Sprintf("dsn: %s\nfoo: %s\n", dsn, foo)
28
29 tdir := os.TempDir() + "/" + strconv.Itoa(time.Now().Nanosecond())
30 require.NoError(t,
31 os.MkdirAll(tdir,
32 os.ModePerm))
33 configFile, err := ioutil.TempFile(tdir, "config-*.yml")
34 _, err = io.WriteString(configFile, config)
35 require.NoError(t, err)
36 require.NoError(t, configFile.Sync())
37 t.Cleanup(func() {
38 fmt.Printf("removing %s\n", configFile.Name())
39 _ = os.Remove(configFile.Name())
40 })
41
42 return configFile
43 }
44
45 func updateConfigFile(t *testing.T, c <-chan struct{}, configFile *os.File, dsn, foo, bar string) {
46 config := fmt.Sprintf(`dsn: %s
47 foo: %s
48 bar: %s`, dsn, foo, bar)
49
50 _, err := configFile.Seek(0, 0)
51 require.NoError(t, err)
52 require.NoError(t, configFile.Truncate(0))
53 _, err = io.WriteString(configFile, config)
54 require.NoError(t, configFile.Sync())
55 <-c
56 }
57
58 func checkLsof(t *testing.T, file string) string {
59 if runtime.GOOS == "windows" {
60 return ""
61 }
62 var b bytes.Buffer
63 c := exec.Command("bash", "-c", "lsof -n | grep "+file+" | wc -l")
64 c.Stdout = &b
65 require.NoError(t, c.Run())
66 return b.String()
67 }
68
69 func TestReload(t *testing.T) {
70 setup := func(t *testing.T, cf *os.File, c chan<- struct{}, modifiers ...OptionModifier) (*Provider, *logrusx.Logger) {
71 l := logrusx.New("configx", "test")
72 ctx, cancel := context.WithCancel(context.Background())
73 t.Cleanup(cancel)
74 modifiers = append(modifiers,
75 WithLogrusWatcher(l),
76 WithLogger(l),
77 AttachWatcher(func(event watcherx.Event, err error) {
78 t.Logf("Received event: %+v error: %+v", event, err)
79 c <- struct{}{}
80 }),
81 WithContext(ctx),
82 )
83 p, err := newKoanf("./stub/watch/config.schema.json", []string{cf.Name()}, modifiers...)
84 require.NoError(t, err)
85 return p, l
86 }
87
88 t.Run("case=rejects not validating changes", func(t *testing.T) {
89 configFile := tmpConfigFile(t, "memory", "bar")
90 defer configFile.Close()
91 c := make(chan struct{})
92 p, l := setup(t, configFile, c)
93 hook := test.NewLocal(l.Entry.Logger)
94
95 atStart := checkLsof(t, configFile.Name())
96
97 assert.Equal(t, []*logrus.Entry{}, hook.AllEntries())
98 assert.Equal(t, "memory", p.String("dsn"))
99 assert.Equal(t, "bar", p.String("foo"))
100
101 updateConfigFile(t, c, configFile, "memory", "not bar", "bar")
102
103 entries := hook.AllEntries()
104 require.Equal(t, 2, len(entries))
105
106 assert.Equal(t, "A change to a configuration file was detected.", entries[0].Message)
107 assert.Equal(t, "The changed configuration is invalid and could not be loaded. Rolling back to the last working configuration revision. Please address the validation errors before restarting the process.", entries[1].Message)
108
109 assert.Equal(t, "memory", p.String("dsn"))
110 assert.Equal(t, "bar", p.String("foo"))
111
112
113 updateConfigFile(t, c, configFile, "memory", "bar", "baz")
114 assert.Equal(t, "baz", p.String("bar"))
115
116 atEnd := checkLsof(t, configFile.Name())
117 require.EqualValues(t, atStart, atEnd)
118 })
119
120 t.Run("case=rejects to update immutable", func(t *testing.T) {
121 configFile := tmpConfigFile(t, "memory", "bar")
122 defer configFile.Close()
123 c := make(chan struct{})
124 p, l := setup(t, configFile, c,
125 WithImmutables("dsn"))
126 hook := test.NewLocal(l.Entry.Logger)
127
128 atStart := checkLsof(t, configFile.Name())
129
130 assert.Equal(t, []*logrus.Entry{}, hook.AllEntries())
131 assert.Equal(t, "memory", p.String("dsn"))
132 assert.Equal(t, "bar", p.String("foo"))
133
134 updateConfigFile(t, c, configFile, "some db", "bar", "baz")
135
136 entries := hook.AllEntries()
137 require.Equal(t, 2, len(entries))
138 assert.Equal(t, "A change to a configuration file was detected.", entries[0].Message)
139 assert.Equal(t, "A configuration value marked as immutable has changed. Rolling back to the last working configuration revision. To reload the values please restart the process.", entries[1].Message)
140 assert.Equal(t, "memory", p.String("dsn"))
141 assert.Equal(t, "bar", p.String("foo"))
142
143
144 updateConfigFile(t, c, configFile, "memory", "bar", "baz")
145 assert.Equal(t, "baz", p.String("bar"))
146
147 atEnd := checkLsof(t, configFile.Name())
148 require.EqualValues(t, atStart, atEnd)
149 })
150
151 t.Run("case=runs without validation errors", func(t *testing.T) {
152 configFile := tmpConfigFile(t, "some string", "bar")
153 defer configFile.Close()
154 c := make(chan struct{})
155 p, l := setup(t, configFile, c)
156 hook := test.NewLocal(l.Entry.Logger)
157
158 assert.Equal(t, []*logrus.Entry{}, hook.AllEntries())
159 assert.Equal(t, "some string", p.String("dsn"))
160 assert.Equal(t, "bar", p.String("foo"))
161 })
162
163 t.Run("case=runs and reloads", func(t *testing.T) {
164 configFile := tmpConfigFile(t, "some string", "bar")
165 defer configFile.Close()
166 c := make(chan struct{})
167 p, l := setup(t, configFile, c)
168 hook := test.NewLocal(l.Entry.Logger)
169
170 assert.Equal(t, []*logrus.Entry{}, hook.AllEntries())
171 assert.Equal(t, "some string", p.String("dsn"))
172 assert.Equal(t, "bar", p.String("foo"))
173
174 updateConfigFile(t, c, configFile, "memory", "bar", "baz")
175 assert.Equal(t, "baz", p.String("bar"))
176 })
177
178 t.Run("case=has with validation errors", func(t *testing.T) {
179 configFile := tmpConfigFile(t, "some string", "not bar")
180 defer configFile.Close()
181 l := logrusx.New("", "")
182 hook := test.NewLocal(l.Entry.Logger)
183
184 var b bytes.Buffer
185 _, err := newKoanf("./stub/watch/config.schema.json", []string{configFile.Name()},
186 WithStandardValidationReporter(&b),
187 WithLogrusWatcher(l),
188 )
189 require.Error(t, err)
190
191 entries := hook.AllEntries()
192 require.Equal(t, 0, len(entries))
193 assert.Equal(t, "The configuration contains values or keys which are invalid:\nfoo: not bar\n ^-- value must be \"bar\"\n\n", b.String())
194 })
195
196 t.Run("case=is not leaking open files", func(t *testing.T) {
197 if runtime.GOOS == "windows" {
198 t.Skip()
199 }
200
201 configFile := tmpConfigFile(t, "some string", "bar")
202 defer configFile.Close()
203 c := make(chan struct{})
204 p, _ := setup(t, configFile, c)
205
206 atStart := checkLsof(t, configFile.Name())
207 for i := 0; i < 30; i++ {
208 t.Run(fmt.Sprintf("iteration=%d", i), func(t *testing.T) {
209 expected := []string{"foo", "bar", "baz"}[i%3]
210 updateConfigFile(t, c, configFile, "memory", "bar", expected)
211 require.EqualValues(t, atStart, checkLsof(t, configFile.Name()))
212 require.EqualValues(t, expected, p.String("bar"))
213 })
214 }
215
216 atEnd := checkLsof(t, configFile.Name())
217 require.EqualValues(t, atStart, atEnd)
218
219 atStartNum, err := strconv.ParseInt(strings.TrimSpace(atStart), 10, 32)
220 require.NoError(t, err)
221 require.True(t, atStartNum < 20, "should not be unreasonably high: %s", atStartNum)
222 })
223 }
224
View as plain text