Skip to content

Commit 7d0ebcd

Browse files
test(source): Consul KV integration tests (#901)
* test(source): add unit and integration tests for Consul KV source * test(source): add integration tests for Consul KV source * redirect consul agent logs to /dev/null unless -v * test(source): add integration tests for Consul KV source
1 parent 7662888 commit 7d0ebcd

3 files changed

Lines changed: 198 additions & 4 deletions

File tree

.github/workflows/go-tests.yml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,14 @@ jobs:
2424
go install gotest.tools/gotestsum@latest
2525
make test-certs
2626
27-
# install nomad
28-
- name: Install Nomad
27+
# install nomad and consul. consul is required by the Consul KV variable
28+
# source integration tests; the tests fail hard if consul is not on $PATH.
29+
- name: Install Nomad and Consul
2930
run : |
3031
sudo apt -y install wget gpg coreutils
3132
wget -O- https://apt.releases.hashicorp.com/gpg | gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
3233
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
33-
sudo apt update && sudo apt -y install nomad
34+
sudo apt update && sudo apt -y install nomad consul
3435
3536
# Run tests with nice formatting. Save the original log in /tmp/gotest.log
3637
- name: Run tests

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ require (
2222
github.com/fatih/color v1.19.0
2323
github.com/go-git/go-git/v5 v5.19.1
2424
github.com/hashicorp/consul/api v1.33.4
25+
github.com/hashicorp/consul/sdk v0.17.2
2526
github.com/hashicorp/go-getter v1.8.6
2627
github.com/hashicorp/go-hclog v1.6.3
2728
github.com/hashicorp/go-multierror v1.1.1
@@ -193,7 +194,6 @@ require (
193194
github.com/hashicorp/cap v0.12.0 // indirect
194195
github.com/hashicorp/cli v1.1.7 // indirect
195196
github.com/hashicorp/consul-template v0.41.4 // indirect
196-
github.com/hashicorp/consul/sdk v0.17.2 // indirect
197197
github.com/hashicorp/cronexpr v1.1.3 // indirect
198198
github.com/hashicorp/errwrap v1.1.0 // indirect
199199
github.com/hashicorp/go-bexpr v0.1.16 // indirect
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
// Copyright IBM Corp. 2023, 2026
2+
// SPDX-License-Identifier: MPL-2.0
3+
4+
package source
5+
6+
import (
7+
"io"
8+
"testing"
9+
10+
"github.com/hashicorp/consul/api"
11+
consultest "github.com/hashicorp/consul/sdk/testutil"
12+
"github.com/hashicorp/nomad-pack/sdk/pack"
13+
"github.com/hashicorp/nomad-pack/sdk/pack/variables"
14+
"github.com/hashicorp/nomad/ci"
15+
"github.com/shoenig/test/must"
16+
"github.com/zclconf/go-cty/cty"
17+
)
18+
19+
func startTestConsul(t *testing.T) *consultest.TestServer {
20+
t.Helper()
21+
22+
srv, err := consultest.NewTestServerConfigT(t, func(c *consultest.TestServerConfig) {
23+
c.Peering = nil
24+
if !testing.Verbose() {
25+
c.Stdout = io.Discard
26+
c.Stderr = io.Discard
27+
}
28+
})
29+
if err != nil {
30+
t.Fatalf("failed to start Consul test server: %v", err)
31+
}
32+
t.Cleanup(func() { _ = srv.Stop() })
33+
34+
srv.WaitForLeader(t)
35+
return srv
36+
}
37+
38+
func newSourceForServer(t *testing.T, srv *consultest.TestServer, path string) *ConsulSource {
39+
t.Helper()
40+
cfg := api.DefaultConfig()
41+
cfg.Address = srv.HTTPAddr
42+
src, err := NewConsulSource(PriorityConsul, cfg, path)
43+
must.NoError(t, err)
44+
return src
45+
}
46+
47+
func varsByName(vars []*variables.Variable) map[string]*variables.Variable {
48+
out := make(map[string]*variables.Variable, len(vars))
49+
for _, v := range vars {
50+
out[string(v.Name)] = v
51+
}
52+
return out
53+
}
54+
55+
func TestConsulSource_Fetch(t *testing.T) {
56+
ci.Parallel(t)
57+
58+
srv := startTestConsul(t)
59+
60+
packID := pack.ID("webapp")
61+
schema := map[variables.ID]*variables.Variable{
62+
"replicas": {Name: "replicas", Type: cty.Number},
63+
"region": {Name: "region", Type: cty.String},
64+
"name": {Name: "name", Type: cty.String},
65+
}
66+
67+
t.Run("fetches typed variables from KV path", func(t *testing.T) {
68+
srv.SetKVString(t, "deploy/webapp/replicas", "3")
69+
srv.SetKVString(t, "deploy/webapp/region", "us-west-2")
70+
71+
src := newSourceForServer(t, srv, "deploy/webapp")
72+
vars, err := src.Fetch(t.Context(), packID, schema)
73+
must.NoError(t, err)
74+
must.Len(t, 2, vars)
75+
76+
got := varsByName(vars)
77+
replicas, _ := got["replicas"].Value.AsBigFloat().Int64()
78+
must.Eq(t, int64(3), replicas)
79+
must.Eq(t, "us-west-2", got["region"].Value.AsString())
80+
})
81+
82+
t.Run("keys not in pack schema are ignored", func(t *testing.T) {
83+
srv.SetKVString(t, "staging/webapp/region", "us-east-1")
84+
srv.SetKVString(t, "staging/webapp/not_in_pack", "ignored")
85+
86+
src := newSourceForServer(t, srv, "staging/webapp")
87+
vars, err := src.Fetch(t.Context(), packID, schema)
88+
must.NoError(t, err)
89+
must.Len(t, 1, vars)
90+
must.Eq(t, "region", string(vars[0].Name))
91+
})
92+
93+
t.Run("empty value for non-string variable is an error", func(t *testing.T) {
94+
srv.SetKVString(t, "prod/webapp/replicas", "")
95+
srv.SetKVString(t, "prod/webapp/region", "us-west-1")
96+
97+
src := newSourceForServer(t, srv, "prod/webapp")
98+
_, err := src.Fetch(t.Context(), packID, schema)
99+
must.ErrorContains(t, err, "empty Consul value")
100+
})
101+
102+
t.Run("object with optional field missing is valid", func(t *testing.T) {
103+
objSchema := map[variables.ID]*variables.Variable{
104+
"svc": {
105+
Name: "svc",
106+
Type: cty.Object(map[string]cty.Type{
107+
"name": cty.String,
108+
"port": cty.Number,
109+
}),
110+
ConstraintType: cty.ObjectWithOptionalAttrs(
111+
map[string]cty.Type{"name": cty.String, "port": cty.Number},
112+
[]string{"port"},
113+
),
114+
},
115+
}
116+
srv.SetKVString(t, "services/webapp/svc", `{"name":"api"}`)
117+
118+
src := newSourceForServer(t, srv, "services/webapp")
119+
vars, err := src.Fetch(t.Context(), packID, objSchema)
120+
must.NoError(t, err)
121+
must.Len(t, 1, vars)
122+
must.Eq(t, "api", vars[0].Value.GetAttr("name").AsString())
123+
must.True(t, vars[0].Value.GetAttr("port").IsNull())
124+
})
125+
126+
t.Run("bool variable is decoded from JSON", func(t *testing.T) {
127+
boolSchema := map[variables.ID]*variables.Variable{
128+
"enabled": {Name: "enabled", Type: cty.Bool},
129+
}
130+
srv.SetKVString(t, "config/webapp/enabled", "true")
131+
132+
src := newSourceForServer(t, srv, "config/webapp")
133+
vars, err := src.Fetch(t.Context(), packID, boolSchema)
134+
must.NoError(t, err)
135+
must.Len(t, 1, vars)
136+
must.True(t, vars[0].Value.True())
137+
})
138+
139+
t.Run("malformed JSON for non-string variable is an error", func(t *testing.T) {
140+
srv.SetKVString(t, "broken/webapp/replicas", "not-a-number")
141+
142+
src := newSourceForServer(t, srv, "broken/webapp")
143+
_, err := src.Fetch(t.Context(), packID, schema)
144+
must.ErrorContains(t, err, "decoding Consul value")
145+
})
146+
147+
t.Run("empty string value is kept for string variable", func(t *testing.T) {
148+
srv.SetKVString(t, "defaults/webapp/name", "")
149+
150+
src := newSourceForServer(t, srv, "defaults/webapp")
151+
vars, err := src.Fetch(t.Context(), packID, schema)
152+
must.NoError(t, err)
153+
must.Len(t, 1, vars)
154+
must.Eq(t, "", vars[0].Value.AsString())
155+
})
156+
157+
t.Run("path with no keys returns empty result", func(t *testing.T) {
158+
src := newSourceForServer(t, srv, "empty/webapp")
159+
vars, err := src.Fetch(t.Context(), packID, schema)
160+
must.NoError(t, err)
161+
must.Len(t, 0, vars)
162+
})
163+
164+
t.Run("consul unavailable returns list error", func(t *testing.T) {
165+
cfg := api.DefaultConfig()
166+
cfg.Address = "127.0.0.1:19998"
167+
src, err := NewConsulSource(PriorityConsul, cfg, "any/path")
168+
must.NoError(t, err)
169+
_, err = src.Fetch(t.Context(), packID, schema)
170+
must.ErrorContains(t, err, "failed to list Consul KV")
171+
})
172+
173+
t.Run("path with surrounding slashes fetches correctly", func(t *testing.T) {
174+
srv.SetKVString(t, "norm/webapp/region", "ap-southeast-1")
175+
176+
src := newSourceForServer(t, srv, "/norm/webapp/")
177+
vars, err := src.Fetch(t.Context(), packID, schema)
178+
must.NoError(t, err)
179+
must.Len(t, 1, vars)
180+
must.Eq(t, "ap-southeast-1", varsByName(vars)["region"].Value.AsString())
181+
})
182+
183+
t.Run("keys with trailing slash are skipped", func(t *testing.T) {
184+
srv.SetKVString(t, "nested/webapp/region", "us-east-1")
185+
srv.SetKVString(t, "nested/webapp/subdir/", "ignored")
186+
187+
src := newSourceForServer(t, srv, "nested/webapp")
188+
vars, err := src.Fetch(t.Context(), packID, schema)
189+
must.NoError(t, err)
190+
must.Len(t, 1, vars)
191+
must.Eq(t, "region", string(vars[0].Name))
192+
})
193+
}

0 commit comments

Comments
 (0)