v3.0.0-alpha.2 7289826

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		{"env secret ref", func(s *domain.AppSpec) { s.Env["X"] = "${sops:y}" }, "secret references"},
46		{"http bad tls", func(s *domain.AppSpec) { s.Services[0].HTTP[0].TLS = "sometimes" }, "auto|always|never"},
47		{"http readiness authority", func(s *domain.AppSpec) { s.Services[0].Readiness.Path = "@127.0.0.1:9000/private" }, "origin-form path"},
48		{"http readiness absolute URL", func(s *domain.AppSpec) { s.Services[0].Readiness.Path = "http://127.0.0.1/private" }, "origin-form path"},
49		{"http readiness scheme relative", func(s *domain.AppSpec) { s.Services[0].Readiness.Path = "//127.0.0.1/private" }, "origin-form path"},
50		{"network unknown service", func(s *domain.AppSpec) {
51			s.Networks = []domain.AppSharedNetwork{{Network: "n", Services: []string{"ghost"}}}
52		}, "unknown service"},
53		{"network bad alias", func(s *domain.AppSpec) {
54			s.Networks = []domain.AppSharedNetwork{{Network: "n", Services: []string{"web"}, Aliases: []string{"Bad_Alias!"}}}
55		}, "DNS label"},
56	}
57	for _, tc := range cases {
58		t.Run(tc.name, func(t *testing.T) {
59			spec := validSpec()
60			tc.mutate(&spec)
61			err := spec.Validate()
62			require.Error(t, err)
63			assert.ErrorIs(t, err, domain.ErrInvalidAppSpec)
64			assert.Contains(t, err.Error(), tc.wantErr)
65		})
66	}
67}
68
69func TestAppIdentityHelpers(t *testing.T) {
70	assert.Equal(t, "gordon-blog--web", domain.LogicalServiceIdentity("blog", "web"))
71	assert.Equal(t, "gordon-blog--a-b", domain.LogicalServiceIdentity("blog", "a.b"))
72	assert.Equal(t,
73		"gordon-blog--db--vol--pgdata",
74		domain.RuntimeVolumeName("blog", "db", "pgdata"))
75	assert.Equal(t,
76		"gordon/apps/blog/web/database-url",
77		domain.AppSecretPath("blog", "web", "database-url"))
78	assert.Equal(t,
79		"gordon/apps/app-uuid-1/web/database-url",
80		domain.AppSecretPathForID("app-uuid-1", "blog", "web", "database-url"))
81	assert.Equal(t,
82		"gordon/apps/blog/web/database-url",
83		domain.AppSecretPathForID("", "blog", "web", "database-url"))
84	assert.Equal(t, "blog.example.com", domain.CanonicalHTTPHost("Blog.Example.COM."))
85}
86
87func TestParsePublish(t *testing.T) {
88	host, port, err := domain.ParsePublish("127.0.0.1:8080")
89	require.NoError(t, err)
90	assert.Equal(t, "127.0.0.1", host)
91	assert.Equal(t, 8080, port)
92
93	host, port, err = domain.ParsePublish("8080")
94	require.NoError(t, err)
95	assert.Empty(t, host)
96	assert.Equal(t, 8080, port)
97
98	_, _, err = domain.ParsePublish("example.com:8080")
99	require.ErrorIs(t, err, domain.ErrInvalidAppSpec)
100
101	_, _, err = domain.ParsePublish("8080:9090:extra")
102	require.Error(t, err)
103
104	_, _, err = domain.ParsePublish("")
105	require.ErrorIs(t, err, domain.ErrInvalidAppSpec)
106
107	_, _, err = domain.ParsePublish("0")
108	require.ErrorIs(t, err, domain.ErrInvalidAppSpec)
109}
110
111func TestDiffAppSpec_DeterministicAndRedacted(t *testing.T) {
112	before := validSpec()
113	after := validSpec()
114	after.Services = append(after.Services, domain.AppService{
115		Name: "worker", Image: "img:1",
116		StopGrace: 10 * time.Second,
117		Readiness: domain.AppReadiness{Type: "none", Timeout: 30 * time.Second},
118	})
119	after.Services[0].Image = "registry.example.com/blog/web:1.5.0"
120	after.Services[0].StopGrace = 20 * time.Second
121	after.Services[0].Secrets = map[string]string{"DATABASE_URL": "database-url-v2"}
122	after.Services[0].Databases = []domain.AppDatabase{{Name: "main", Type: "postgres", Schedule: "daily"}}
123	after.Services[0].Backup = domain.AppBackup{Postgres: []string{"main"}}
124	after.Networks = []domain.AppSharedNetwork{{Network: "shared", Services: []string{"web"}}}
125	after.Env["NEW_KEY"] = "v"
126
127	diff := domain.DiffAppSpec(after, before)
128	assert.Equal(t, []string{"service/worker"}, diff.Added)
129	assert.Empty(t, diff.Removed)
130	assert.Contains(t, diff.Changed, "service/web/image")
131	assert.Contains(t, diff.Changed, "service/web/stop_grace")
132	assert.Contains(t, diff.Changed, "service/web/databases")
133	assert.Contains(t, diff.Changed, "service/web/backup")
134	assert.Contains(t, diff.Changed, "network/shared/added")
135	assert.Contains(t, diff.Changed, "service/web/secret/database-url-v2")
136	assert.Contains(t, diff.Changed, "service/web/secret/database-url")
137	assert.Contains(t, diff.Changed, "env")
138	// Sorted output.
139	assert.IsIncreasing(t, diff.Changed)
140	// No secret values leak: only names/paths.
141	for _, entry := range append(append(diff.Added, diff.Removed...), diff.Changed...) {
142		assert.NotContains(t, entry, "s3cr3t")
143	}
144
145	// Identical specs produce an empty diff.
146	empty := domain.DiffAppSpec(before, validSpec())
147	assert.Empty(t, empty.Added)
148	assert.Empty(t, empty.Removed)
149	assert.Empty(t, empty.Changed)
150
151	// Removed service.
152	removed := domain.DiffAppSpec(domain.AppSpec{Name: "x"}, before)
153	assert.Equal(t, []string{"service/web"}, removed.Removed)
154}
155
156func TestDiffAppSpec_NetworkDetailsAndOrderNormalization(t *testing.T) {
157	base := validSpec()
158	base.Networks = []domain.AppSharedNetwork{{Network: "database", Services: []string{"web"}, Aliases: []string{"db", "primary"}}}
159
160	reordered := validSpec()
161	reordered.Networks = []domain.AppSharedNetwork{{Network: "database", Services: []string{"web"}, Aliases: []string{"primary", "db"}}}
162	assert.Empty(t, domain.DiffAppSpec(reordered, base).Changed)
163
164	servicesChanged := validSpec()
165	servicesChanged.Services = append(servicesChanged.Services, domain.AppService{Name: "worker", Image: "img:1", Readiness: domain.AppReadiness{Type: "none", Timeout: 30 * time.Second}})
166	servicesChanged.Networks = []domain.AppSharedNetwork{{Network: "database", Services: []string{"web", "worker"}, Aliases: []string{"db", "primary"}}}
167	assert.Contains(t, domain.DiffAppSpec(servicesChanged, base).Changed, "network/database/services")
168
169	aliasesChanged := base
170	aliasesChanged.Networks = []domain.AppSharedNetwork{{Network: "database", Services: []string{"web"}, Aliases: []string{"db"}}}
171	assert.Equal(t, []string{"network/database/aliases"}, domain.DiffAppSpec(aliasesChanged, base).Changed)
172
173	removed := validSpec()
174	assert.Equal(t, []string{"network/database/removed"}, domain.DiffAppSpec(removed, base).Changed)
175}
176
177func TestDiffAppSpec_ErrorsWrapped(t *testing.T) {
178	assert.True(t, errors.Is(domain.AppSpec{}.Validate(), domain.ErrInvalidAppSpec))
179}

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