app_spec_test.go RawHistoryFolder

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")
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"}, domain.DiffAppSpec(changed, base).Changed)
200	assert.Equal(t, []string{"service/web/env"}, 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"}, domain.DiffAppSpec(changed, other).Changed)
208}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close