1package domain_test
2
3import (
4 "errors"
5 "testing"
6 "time"
7
8 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/require"
10
11 "github.com/bnema/gordon/internal/domain"
12)
13
14func validSpec() domain.AppSpec {
15 return domain.AppSpec{
16 Name: "blog",
17 Env: map[string]string{"APP_ENV": "production"},
18 Services: []domain.AppService{
19 {
20 Name: "web",
21 Image: "registry.example.com/blog/web:1.4.2",
22 StopGrace: 10 * time.Second,
23 Readiness: domain.AppReadiness{Type: "http", Path: "/healthz", Timeout: 30 * time.Second},
24 HTTP: []domain.AppHTTPInterface{{Host: "blog.example.com", Port: 8080, TLS: "auto"}},
25 Secrets: map[string]string{"DATABASE_URL": "database-url"},
26 },
27 },
28 }
29}
30
31func TestAppSpec_ValidateOK(t *testing.T) {
32 require.NoError(t, validSpec().Validate())
33}
34
35func TestAppSpec_ValidateErrors(t *testing.T) {
36 cases := []struct {
37 name string
38 mutate func(*domain.AppSpec)
39 wantErr string
40 }{
41 {"bad app", func(s *domain.AppSpec) { s.Name = "Bad!" }, "app name"},
42 {"no services", func(s *domain.AppSpec) { s.Services = nil }, "at least one service"},
43 {"no image", func(s *domain.AppSpec) { s.Services[0].Image = "" }, "requires an image"},
44 {"env newline", func(s *domain.AppSpec) { s.Env["X"] = "a\nb" }, "newlines"},
45 {"service env newline", func(s *domain.AppSpec) { s.Services[0].Env = map[string]string{"X": "a\nb"} }, "newlines"},
46 {"service env secret ref", func(s *domain.AppSpec) { s.Services[0].Env = map[string]string{"X": "${sops:y}"} }, "secret references"},
47 {"service env empty value", func(s *domain.AppSpec) { s.Services[0].Env = map[string]string{"X": ""} }, "1-"},
48 {"service env bad key", func(s *domain.AppSpec) { s.Services[0].Env = map[string]string{"1bad": "v"} }, "env key"},
49 {"service env secret collision", func(s *domain.AppSpec) {
50 s.Services[0].Env = map[string]string{"DATABASE_URL": "x"}
51 }, "collides with a secret key"},
52 {"env secret ref", func(s *domain.AppSpec) { s.Env["X"] = "${sops:y}" }, "secret references"},
53 {"http bad tls", func(s *domain.AppSpec) { s.Services[0].HTTP[0].TLS = "sometimes" }, "auto|always|never"},
54 {"http readiness authority", func(s *domain.AppSpec) { s.Services[0].Readiness.Path = "@127.0.0.1:9000/private" }, "origin-form path"},
55 {"http readiness absolute URL", func(s *domain.AppSpec) { s.Services[0].Readiness.Path = "http://127.0.0.1/private" }, "origin-form path"},
56 {"http readiness scheme relative", func(s *domain.AppSpec) { s.Services[0].Readiness.Path = "//127.0.0.1/private" }, "origin-form path"},
57 {"network unknown service", func(s *domain.AppSpec) {
58 s.Networks = []domain.AppSharedNetwork{{Network: "n", Services: []string{"ghost"}}}
59 }, "unknown service"},
60 {"network bad alias", func(s *domain.AppSpec) {
61 s.Networks = []domain.AppSharedNetwork{{Network: "n", Services: []string{"web"}, Aliases: []string{"Bad_Alias!"}}}
62 }, "DNS label"},
63 }
64 for _, tc := range cases {
65 t.Run(tc.name, func(t *testing.T) {
66 spec := validSpec()
67 tc.mutate(&spec)
68 err := spec.Validate()
69 require.Error(t, err)
70 assert.ErrorIs(t, err, domain.ErrInvalidAppSpec)
71 assert.Contains(t, err.Error(), tc.wantErr)
72 })
73 }
74}
75
76func TestAppIdentityHelpers(t *testing.T) {
77 assert.Equal(t, "gordon-blog--web", domain.LogicalServiceIdentity("blog", "web"))
78 assert.Equal(t, "gordon-blog--a-b", domain.LogicalServiceIdentity("blog", "a.b"))
79 assert.Equal(t,
80 "gordon-blog--db--vol--pgdata",
81 domain.RuntimeVolumeName("blog", "db", "pgdata"))
82 assert.Equal(t,
83 "gordon/apps/blog/web/database-url",
84 domain.AppSecretPath("blog", "web", "database-url"))
85 assert.Equal(t,
86 "gordon/apps/app-uuid-1/web/database-url",
87 domain.AppSecretPathForID("app-uuid-1", "blog", "web", "database-url"))
88 assert.Equal(t,
89 "gordon/apps/blog/web/database-url",
90 domain.AppSecretPathForID("", "blog", "web", "database-url"))
91 assert.Equal(t, "blog.example.com", domain.CanonicalHTTPHost("Blog.Example.COM."))
92}
93
94func TestParsePublish(t *testing.T) {
95 host, port, err := domain.ParsePublish("127.0.0.1:8080")
96 require.NoError(t, err)
97 assert.Equal(t, "127.0.0.1", host)
98 assert.Equal(t, 8080, port)
99
100 host, port, err = domain.ParsePublish("8080")
101 require.NoError(t, err)
102 assert.Empty(t, host)
103 assert.Equal(t, 8080, port)
104
105 _, _, err = domain.ParsePublish("example.com:8080")
106 require.ErrorIs(t, err, domain.ErrInvalidAppSpec)
107
108 _, _, err = domain.ParsePublish("8080:9090:extra")
109 require.Error(t, err)
110
111 _, _, err = domain.ParsePublish("")
112 require.ErrorIs(t, err, domain.ErrInvalidAppSpec)
113
114 _, _, err = domain.ParsePublish("0")
115 require.ErrorIs(t, err, domain.ErrInvalidAppSpec)
116}
117
118func TestDiffAppSpec_DeterministicAndRedacted(t *testing.T) {
119 before := validSpec()
120 after := validSpec()
121 after.Services = append(after.Services, domain.AppService{
122 Name: "worker", Image: "img:1",
123 StopGrace: 10 * time.Second,
124 Readiness: domain.AppReadiness{Type: "none", Timeout: 30 * time.Second},
125 })
126 after.Services[0].Image = "registry.example.com/blog/web:1.5.0"
127 after.Services[0].StopGrace = 20 * time.Second
128 after.Services[0].Secrets = map[string]string{"DATABASE_URL": "database-url-v2"}
129 after.Services[0].Databases = []domain.AppDatabase{{Name: "main", Type: "postgres", Schedule: "daily"}}
130 after.Services[0].Backup = domain.AppBackup{Postgres: []string{"main"}}
131 after.Networks = []domain.AppSharedNetwork{{Network: "shared", Services: []string{"web"}}}
132 after.Env["NEW_KEY"] = "v"
133
134 diff := domain.DiffAppSpec(after, before)
135 assert.Equal(t, []string{"service/worker"}, diff.Added)
136 assert.Empty(t, diff.Removed)
137 assert.Contains(t, diff.Changed, "service/web/image")
138 assert.Contains(t, diff.Changed, "service/web/stop_grace")
139 assert.Contains(t, diff.Changed, "service/web/databases")
140 assert.Contains(t, diff.Changed, "service/web/backup")
141 assert.Contains(t, diff.Changed, "network/shared/added")
142 assert.Contains(t, diff.Changed, "service/web/secret/database-url-v2")
143 assert.Contains(t, diff.Changed, "service/web/secret/database-url")
144 assert.Contains(t, diff.Changed, "env/NEW_KEY")
145 // Sorted output.
146 assert.IsIncreasing(t, diff.Changed)
147 // No secret values leak: only names/paths.
148 for _, entry := range append(append(diff.Added, diff.Removed...), diff.Changed...) {
149 assert.NotContains(t, entry, "s3cr3t")
150 }
151
152 // Identical specs produce an empty diff.
153 empty := domain.DiffAppSpec(before, validSpec())
154 assert.Empty(t, empty.Added)
155 assert.Empty(t, empty.Removed)
156 assert.Empty(t, empty.Changed)
157
158 // Removed service.
159 removed := domain.DiffAppSpec(domain.AppSpec{Name: "x"}, before)
160 assert.Equal(t, []string{"service/web"}, removed.Removed)
161}
162
163func TestDiffAppSpec_NetworkDetailsAndOrderNormalization(t *testing.T) {
164 base := validSpec()
165 base.Networks = []domain.AppSharedNetwork{{Network: "database", Services: []string{"web"}, Aliases: []string{"db", "primary"}}}
166
167 reordered := validSpec()
168 reordered.Networks = []domain.AppSharedNetwork{{Network: "database", Services: []string{"web"}, Aliases: []string{"primary", "db"}}}
169 assert.Empty(t, domain.DiffAppSpec(reordered, base).Changed)
170
171 servicesChanged := validSpec()
172 servicesChanged.Services = append(servicesChanged.Services, domain.AppService{Name: "worker", Image: "img:1", Readiness: domain.AppReadiness{Type: "none", Timeout: 30 * time.Second}})
173 servicesChanged.Networks = []domain.AppSharedNetwork{{Network: "database", Services: []string{"web", "worker"}, Aliases: []string{"db", "primary"}}}
174 assert.Contains(t, domain.DiffAppSpec(servicesChanged, base).Changed, "network/database/services")
175
176 aliasesChanged := base
177 aliasesChanged.Networks = []domain.AppSharedNetwork{{Network: "database", Services: []string{"web"}, Aliases: []string{"db"}}}
178 assert.Equal(t, []string{"network/database/aliases"}, domain.DiffAppSpec(aliasesChanged, base).Changed)
179
180 removed := validSpec()
181 assert.Equal(t, []string{"network/database/removed"}, domain.DiffAppSpec(removed, base).Changed)
182}
183
184func TestDiffAppSpec_ErrorsWrapped(t *testing.T) {
185 assert.True(t, errors.Is(domain.AppSpec{}.Validate(), domain.ErrInvalidAppSpec))
186}
187
188func TestAppSpec_ServiceEnvOverridesAppEnvAllowed(t *testing.T) {
189 spec := validSpec()
190 spec.Services[0].Env = map[string]string{"APP_ENV": "staging"}
191 require.NoError(t, spec.Validate())
192}
193
194func TestDiffAppSpec_ServiceEnv(t *testing.T) {
195 base := validSpec()
196 changed := validSpec()
197 changed.Services[0].Env = map[string]string{"LOG": "debug"}
198
199 assert.Equal(t, []string{"service/web/env/LOG"}, domain.DiffAppSpec(changed, base).Changed)
200 assert.Equal(t, []string{"service/web/env/LOG"}, domain.DiffAppSpec(base, changed).Changed)
201 assert.Empty(t, domain.DiffAppSpec(changed, changed).Changed)
202 assert.False(t, domain.SameAppService(changed.Services[0], base.Services[0]))
203
204 changed.Services[0].Env["LOG"] = "info"
205 other := validSpec()
206 other.Services[0].Env = map[string]string{"LOG": "debug"}
207 assert.Equal(t, []string{"service/web/env/LOG"}, domain.DiffAppSpec(changed, other).Changed)
208}
209
210func TestDiffAppSpec_EnvListsChangedKeyNamesOnly(t *testing.T) {
211 before := validSpec()
212 before.Env = map[string]string{"KEEP": "1", "EDIT": "old-value", "DROP": "x"}
213 after := validSpec()
214 after.Env = map[string]string{"KEEP": "1", "EDIT": "new-value", "ADD": "y"}
215
216 diff := domain.DiffAppSpec(after, before)
217 assert.Equal(t, []string{"env/ADD", "env/DROP", "env/EDIT"}, diff.Changed)
218 for _, entry := range diff.Changed {
219 assert.NotContains(t, entry, "value")
220 }
221}
222
223func TestActiveAppSpec_UsesActivatedRevisionEnv(t *testing.T) {
224 web := validSpec().Services[0]
225 worker := web
226 worker.Name = "worker"
227 active := domain.AppActive{
228 App: "blog",
229 Services: map[string]domain.AppEffectiveService{
230 "web": {EffectiveRevision: "rev-1", Spec: web},
231 "worker": {EffectiveRevision: "rev-2", Spec: worker},
232 },
233 }
234 envs := map[string]map[string]string{
235 "rev-1": {"MODE": "prod"},
236 "rev-2": {"MODE": "old"},
237 }
238 load := func(revision string) (map[string]string, error) { return envs[revision], nil }
239
240 // Image-only change: desired env equals what every service runs.
241 envs["rev-2"] = map[string]string{"MODE": "prod"}
242 spec, err := domain.ActiveAppSpec(active, map[string]string{"MODE": "prod"}, load)
243 require.NoError(t, err)
244 assert.Equal(t, map[string]string{"MODE": "prod"}, spec.Env)
245 assert.Equal(t, []string{"web", "worker"}, []string{spec.Services[0].Name, spec.Services[1].Name})
246
247 // One service still runs a stale env: the diff must not hide it.
248 envs["rev-2"] = map[string]string{"MODE": "old"}
249 spec, err = domain.ActiveAppSpec(active, map[string]string{"MODE": "prod"}, load)
250 require.NoError(t, err)
251 assert.Equal(t, map[string]string{"MODE": "old"}, spec.Env)
252
253 // A garbage-collected revision counts as an empty env.
254 spec, err = domain.ActiveAppSpec(active, map[string]string{"MODE": "prod"}, func(string) (map[string]string, error) { return nil, nil })
255 require.NoError(t, err)
256 assert.Empty(t, spec.Env)
257
258 _, err = domain.ActiveAppSpec(active, nil, func(string) (map[string]string, error) { return nil, errors.New("boom") })
259 require.Error(t, err)
260}
261
262func TestActiveAppSpec_ReportsStaleKeysFromEveryRevision(t *testing.T) {
263 web := validSpec().Services[0]
264 worker := web
265 worker.Name = "worker"
266 active := domain.AppActive{
267 App: "blog",
268 Services: map[string]domain.AppEffectiveService{
269 "web": {EffectiveRevision: "rev-1", Spec: web},
270 "worker": {EffectiveRevision: "rev-2", Spec: worker},
271 },
272 }
273 envs := map[string]map[string]string{
274 "rev-1": {"A": "old", "B": "new", "C": "same"},
275 "rev-2": {"A": "new", "C": "same", "D": "gone"},
276 }
277 desired := domain.AppSpec{Name: "blog", Env: map[string]string{"A": "new", "B": "new", "C": "same"}}
278 desired.Services = []domain.AppService{web, worker}
279
280 effective, err := domain.ActiveAppSpec(active, desired.Env, func(revision string) (map[string]string, error) {
281 return envs[revision], nil
282 })
283 require.NoError(t, err)
284
285 diff := domain.DiffAppSpec(desired, effective)
286 assert.Equal(t, []string{"env/A", "env/B", "env/D"}, diff.Changed)
287 assert.Equal(t, map[string]string{"A": "new", "B": "new", "C": "same"}, desired.Env, "desired env must not be mutated")
288}
289
290func TestActiveAppSpec_EmptyActiveHasEmptyEnv(t *testing.T) {
291 spec, err := domain.ActiveAppSpec(domain.AppActive{App: "blog"}, map[string]string{"A": "x"}, func(string) (map[string]string, error) {
292 t.Fatal("no revision should be loaded")
293 return nil, nil
294 })
295 require.NoError(t, err)
296 assert.Empty(t, spec.Env)
297 assert.NotNil(t, spec.Env)
298}