From 79cb4081ce734ba5f6641eb9147c2f1c8ae3d1a9 Mon Sep 17 00:00:00 2001 From: TIMMAREDDY DEEKSHITHA Date: Wed, 17 Jun 2026 23:42:09 +0530 Subject: [PATCH 1/3] test(source): add unit and integration tests for Consul KV source --- .github/workflows/go-tests.yml | 7 +- go.mod | 2 +- .../source/consul_source_integration_test.go | 165 ++++++++++++++++++ .../pkg/variable/source/consul_source_test.go | 116 ++++++++++++ 4 files changed, 286 insertions(+), 4 deletions(-) create mode 100644 internal/pkg/variable/source/consul_source_integration_test.go create mode 100644 internal/pkg/variable/source/consul_source_test.go diff --git a/.github/workflows/go-tests.yml b/.github/workflows/go-tests.yml index b7dd0ecb..fa4e3da1 100644 --- a/.github/workflows/go-tests.yml +++ b/.github/workflows/go-tests.yml @@ -24,13 +24,14 @@ jobs: go install gotest.tools/gotestsum@latest make test-certs - # install nomad - - name: Install Nomad + # install nomad and consul. consul is required by the Consul KV variable + # source integration tests; without it on $PATH those tests skip. + - name: Install Nomad and Consul run : | sudo apt -y install wget gpg coreutils wget -O- https://apt.releases.hashicorp.com/gpg | gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg 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 - sudo apt update && sudo apt -y install nomad + sudo apt update && sudo apt -y install nomad consul # Run tests with nice formatting. Save the original log in /tmp/gotest.log - name: Run tests diff --git a/go.mod b/go.mod index 10791f9f..5083ffd4 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/fatih/color v1.19.0 github.com/go-git/go-git/v5 v5.19.1 github.com/hashicorp/consul/api v1.33.4 + github.com/hashicorp/consul/sdk v0.17.2 github.com/hashicorp/go-getter v1.8.6 github.com/hashicorp/go-hclog v1.6.3 github.com/hashicorp/go-multierror v1.1.1 @@ -193,7 +194,6 @@ require ( github.com/hashicorp/cap v0.12.0 // indirect github.com/hashicorp/cli v1.1.7 // indirect github.com/hashicorp/consul-template v0.41.4 // indirect - github.com/hashicorp/consul/sdk v0.17.2 // indirect github.com/hashicorp/cronexpr v1.1.3 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-bexpr v0.1.16 // indirect diff --git a/internal/pkg/variable/source/consul_source_integration_test.go b/internal/pkg/variable/source/consul_source_integration_test.go new file mode 100644 index 00000000..ff88292e --- /dev/null +++ b/internal/pkg/variable/source/consul_source_integration_test.go @@ -0,0 +1,165 @@ +// Copyright IBM Corp. 2023, 2026 +// SPDX-License-Identifier: MPL-2.0 + +package source + +import ( + "io" + "testing" + + "github.com/hashicorp/consul/api" + consultest "github.com/hashicorp/consul/sdk/testutil" + "github.com/hashicorp/nomad-pack/sdk/pack" + "github.com/hashicorp/nomad-pack/sdk/pack/variables" + "github.com/hashicorp/nomad/ci" + "github.com/shoenig/test/must" + "github.com/zclconf/go-cty/cty" +) + +// startTestConsul starts an in-process Consul dev agent for integration tests. +// +// It skips the test (rather than failing) when the consul binary is not on +// PATH, so the suite stays green on developer machines without Consul installed +// while still exercising real Consul in CI, where the binary is installed. This +// mirrors the pattern Nomad core uses for its Consul compatibility tests. +func startTestConsul(t *testing.T) *consultest.TestServer { + t.Helper() + + if testing.Short() { + t.Skip("-short set; skipping Consul integration test") + } + + srv, err := consultest.NewTestServerConfigT(t, func(c *consultest.TestServerConfig) { + c.Peering = nil // older Consul versions don't support peering + if !testing.Verbose() { + // Squelch Consul's logs unless the test run is verbose. + c.Stdout = io.Discard + c.Stderr = io.Discard + } + }) + if err != nil { + t.Skipf("consul not available, skipping integration test: %v", err) + } + t.Cleanup(func() { _ = srv.Stop() }) + + srv.WaitForLeader(t) + return srv +} + +// newSourceForServer builds a ConsulSource pointed at the test server's address. +func newSourceForServer(t *testing.T, srv *consultest.TestServer, prefix string, includePackID bool) *ConsulSource { + t.Helper() + cfg := api.DefaultConfig() + cfg.Address = srv.HTTPAddr + src, err := NewConsulSource(PriorityConsul, cfg, prefix, includePackID) + must.NoError(t, err) + return src +} + +// varsByName indexes a slice of variables by name for convenient assertions. +func varsByName(vars []*variables.Variable) map[string]*variables.Variable { + out := make(map[string]*variables.Variable, len(vars)) + for _, v := range vars { + out[string(v.Name)] = v + } + return out +} + +// TestConsulSource_Fetch_Integration exercises ConsulSource.Fetch against a real +// Consul KV store. A single Consul agent is shared across subtests, and each +// subtest uses a unique KV prefix to stay isolated. +func TestConsulSource_Fetch_Integration(t *testing.T) { + ci.Parallel(t) + + srv := startTestConsul(t) + + packID := pack.ID("webapp") + schema := map[variables.ID]*variables.Variable{ + "replicas": {Name: "replicas", Type: cty.Number}, + "region": {Name: "region", Type: cty.String}, + "name": {Name: "name", Type: cty.String}, + } + + t.Run("reads vars under prefix/pack-id", func(t *testing.T) { + srv.SetKVString(t, "p1/webapp/replicas", "3") + srv.SetKVString(t, "p1/webapp/region", "us-west-2") + + src := newSourceForServer(t, srv, "p1", true) + vars, err := src.Fetch(t.Context(), packID, schema) + must.NoError(t, err) + must.Len(t, 2, vars) + + got := varsByName(vars) + replicas, _ := got["replicas"].Value.AsBigFloat().Int64() + must.Eq(t, int64(3), replicas) + must.Eq(t, "us-west-2", got["region"].Value.AsString()) + }) + + t.Run("full-path mode does not append pack id", func(t *testing.T) { + srv.SetKVString(t, "p2/path/region", "eu-central-1") + + src := newSourceForServer(t, srv, "p2/path", false) + vars, err := src.Fetch(t.Context(), packID, schema) + must.NoError(t, err) + must.Len(t, 1, vars) + must.Eq(t, "eu-central-1", varsByName(vars)["region"].Value.AsString()) + }) + + t.Run("variables not in schema are skipped", func(t *testing.T) { + srv.SetKVString(t, "p3/webapp/region", "us-east-1") + srv.SetKVString(t, "p3/webapp/not_in_pack", "ignored") + + src := newSourceForServer(t, srv, "p3", true) + vars, err := src.Fetch(t.Context(), packID, schema) + must.NoError(t, err) + must.Len(t, 1, vars) + must.Eq(t, "region", string(vars[0].Name)) + }) + + t.Run("empty value for non-string var is skipped so default applies", func(t *testing.T) { + srv.SetKVString(t, "p4/webapp/replicas", "") // empty: can't decode into a number + srv.SetKVString(t, "p4/webapp/region", "us-west-1") + + src := newSourceForServer(t, srv, "p4", true) + vars, err := src.Fetch(t.Context(), packID, schema) + must.NoError(t, err) + // replicas (number) with an empty value is skipped so the pack default + // applies; region (string) is still returned. The whole fetch must not + // fail just because one key is empty. + must.Len(t, 1, vars) + must.Eq(t, "region", string(vars[0].Name)) + }) + + t.Run("object var with optional attribute resolves via constraint type", func(t *testing.T) { + // object({name=string, port=optional(number)}) stored as a partial JSON + // document. Type has optional() stripped; ConstraintType preserves it. + objSchema := map[variables.ID]*variables.Variable{ + "svc": { + Name: "svc", + Type: cty.Object(map[string]cty.Type{ + "name": cty.String, + "port": cty.Number, + }), + ConstraintType: cty.ObjectWithOptionalAttrs( + map[string]cty.Type{"name": cty.String, "port": cty.Number}, + []string{"port"}, + ), + }, + } + srv.SetKVString(t, "p5/webapp/svc", `{"name":"api"}`) + + src := newSourceForServer(t, srv, "p5", true) + vars, err := src.Fetch(t.Context(), packID, objSchema) + must.NoError(t, err) + must.Len(t, 1, vars) + must.Eq(t, "api", vars[0].Value.GetAttr("name").AsString()) + must.True(t, vars[0].Value.GetAttr("port").IsNull()) + }) + + t.Run("no keys under prefix returns no variables", func(t *testing.T) { + src := newSourceForServer(t, srv, "p6-empty", true) + vars, err := src.Fetch(t.Context(), pack.ID("absent"), schema) + must.NoError(t, err) + must.Len(t, 0, vars) + }) +} diff --git a/internal/pkg/variable/source/consul_source_test.go b/internal/pkg/variable/source/consul_source_test.go new file mode 100644 index 00000000..7c788e3f --- /dev/null +++ b/internal/pkg/variable/source/consul_source_test.go @@ -0,0 +1,116 @@ +// Copyright IBM Corp. 2023, 2026 +// SPDX-License-Identifier: MPL-2.0 + +package source + +import ( + "testing" + + "github.com/hashicorp/consul/api" + "github.com/hashicorp/nomad/ci" + "github.com/shoenig/test/must" + "github.com/zclconf/go-cty/cty" +) + +// TestConsulSource_convertValueWithSchema verifies that raw Consul KV bytes are +// converted using the variable's declared type, rather than by guessing the +// type from the value. This is the behavior reviewers asked for: a value is +// only ever coerced into the type the pack actually declares. +func TestConsulSource_convertValueWithSchema(t *testing.T) { + ci.Parallel(t) + + // convertValueWithSchema uses no receiver state, so a zero-value source is + // enough to exercise it. + c := &ConsulSource{} + + t.Run("string type keeps raw bytes even when valid JSON", func(t *testing.T) { + ci.Parallel(t) + // A string variable must preserve the exact bytes, so a JSON document + // stored in Consul stays a string instead of being decoded. This is the + // case that broke when the source guessed the type. + val, err := c.convertValueWithSchema([]byte(`{"hello":"world"}`), cty.String) + must.NoError(t, err) + must.Eq(t, cty.String, val.Type()) + must.Eq(t, `{"hello":"world"}`, val.AsString()) + }) + + t.Run("number type parses JSON number", func(t *testing.T) { + ci.Parallel(t) + val, err := c.convertValueWithSchema([]byte("3"), cty.Number) + must.NoError(t, err) + must.Eq(t, cty.Number, val.Type()) + got, _ := val.AsBigFloat().Int64() + must.Eq(t, int64(3), got) + }) + + t.Run("bool type parses JSON boolean", func(t *testing.T) { + ci.Parallel(t) + val, err := c.convertValueWithSchema([]byte("true"), cty.Bool) + must.NoError(t, err) + must.True(t, val.True()) + }) + + t.Run("list type parses JSON array", func(t *testing.T) { + ci.Parallel(t) + val, err := c.convertValueWithSchema([]byte(`["dc1","dc2"]`), cty.List(cty.String)) + must.NoError(t, err) + must.Eq(t, cty.List(cty.String), val.Type()) + must.Eq(t, 2, val.LengthInt()) + }) + + t.Run("object with optional attribute omitted", func(t *testing.T) { + ci.Parallel(t) + // The pack declares object({name=string, port=optional(number)}). The + // ConstraintType preserves optional(), so a Consul value missing "port" + // must still convert, with port set to null. Converting against the + // plain Type (which strips optional) would fail with "attribute port is + // required". + constraint := cty.ObjectWithOptionalAttrs( + map[string]cty.Type{"name": cty.String, "port": cty.Number}, + []string{"port"}, + ) + val, err := c.convertValueWithSchema([]byte(`{"name":"api"}`), constraint) + must.NoError(t, err) + must.Eq(t, "api", val.GetAttr("name").AsString()) + must.True(t, val.GetAttr("port").IsNull()) + }) + + t.Run("type mismatch returns error", func(t *testing.T) { + ci.Parallel(t) + // A JSON string cannot be coerced into a number. + _, err := c.convertValueWithSchema([]byte(`"not-a-number"`), cty.Number) + must.ErrorContains(t, err, "type mismatch") + }) + + t.Run("invalid JSON for non-string type returns error", func(t *testing.T) { + ci.Parallel(t) + _, err := c.convertValueWithSchema([]byte("not json"), cty.Number) + must.ErrorContains(t, err, "not valid JSON") + }) +} + +// TestNewConsulSource verifies that the source is constructed with a unique, +// descriptive name and a normalized prefix, without making any network calls. +func TestNewConsulSource(t *testing.T) { + ci.Parallel(t) + + t.Run("name encodes address and trimmed prefix", func(t *testing.T) { + ci.Parallel(t) + cfg := api.DefaultConfig() + cfg.Address = "consul.example:8500" + + src, err := NewConsulSource(PriorityConsul, cfg, "/my/prefix/", true) + must.NoError(t, err) + must.Eq(t, PriorityConsul, src.Priority()) + // Leading/trailing slashes are trimmed, and the name embeds the address + // and prefix so multiple Consul sources never collide. + must.Eq(t, "consul(consul.example:8500:my/prefix)", src.Name()) + }) + + t.Run("nil config falls back to Consul defaults", func(t *testing.T) { + ci.Parallel(t) + src, err := NewConsulSource(PriorityConsul, nil, "prefix", false) + must.NoError(t, err) + must.NotNil(t, src) + }) +} From b47cb826323f38598858f3973f0318cbe7007d8d Mon Sep 17 00:00:00 2001 From: TIMMAREDDY DEEKSHITHA Date: Fri, 19 Jun 2026 23:50:00 +0530 Subject: [PATCH 2/3] test(source): add integration tests for Consul KV source --- .github/workflows/go-tests.yml | 2 +- .../source/consul_source_integration_test.go | 113 ++++++++--------- .../pkg/variable/source/consul_source_test.go | 116 ------------------ 3 files changed, 59 insertions(+), 172 deletions(-) delete mode 100644 internal/pkg/variable/source/consul_source_test.go diff --git a/.github/workflows/go-tests.yml b/.github/workflows/go-tests.yml index fa4e3da1..3bcf15a6 100644 --- a/.github/workflows/go-tests.yml +++ b/.github/workflows/go-tests.yml @@ -25,7 +25,7 @@ jobs: make test-certs # install nomad and consul. consul is required by the Consul KV variable - # source integration tests; without it on $PATH those tests skip. + # source integration tests; the tests fail hard if consul is not on $PATH. - name: Install Nomad and Consul run : | sudo apt -y install wget gpg coreutils diff --git a/internal/pkg/variable/source/consul_source_integration_test.go b/internal/pkg/variable/source/consul_source_integration_test.go index ff88292e..cf9fdcab 100644 --- a/internal/pkg/variable/source/consul_source_integration_test.go +++ b/internal/pkg/variable/source/consul_source_integration_test.go @@ -4,7 +4,6 @@ package source import ( - "io" "testing" "github.com/hashicorp/consul/api" @@ -16,29 +15,14 @@ import ( "github.com/zclconf/go-cty/cty" ) -// startTestConsul starts an in-process Consul dev agent for integration tests. -// -// It skips the test (rather than failing) when the consul binary is not on -// PATH, so the suite stays green on developer machines without Consul installed -// while still exercising real Consul in CI, where the binary is installed. This -// mirrors the pattern Nomad core uses for its Consul compatibility tests. func startTestConsul(t *testing.T) *consultest.TestServer { t.Helper() - if testing.Short() { - t.Skip("-short set; skipping Consul integration test") - } - srv, err := consultest.NewTestServerConfigT(t, func(c *consultest.TestServerConfig) { - c.Peering = nil // older Consul versions don't support peering - if !testing.Verbose() { - // Squelch Consul's logs unless the test run is verbose. - c.Stdout = io.Discard - c.Stderr = io.Discard - } + c.Peering = nil }) if err != nil { - t.Skipf("consul not available, skipping integration test: %v", err) + t.Fatalf("failed to start Consul test server: %v", err) } t.Cleanup(func() { _ = srv.Stop() }) @@ -46,17 +30,15 @@ func startTestConsul(t *testing.T) *consultest.TestServer { return srv } -// newSourceForServer builds a ConsulSource pointed at the test server's address. -func newSourceForServer(t *testing.T, srv *consultest.TestServer, prefix string, includePackID bool) *ConsulSource { +func newSourceForServer(t *testing.T, srv *consultest.TestServer, path string) *ConsulSource { t.Helper() cfg := api.DefaultConfig() cfg.Address = srv.HTTPAddr - src, err := NewConsulSource(PriorityConsul, cfg, prefix, includePackID) + src, err := NewConsulSource(PriorityConsul, cfg, path) must.NoError(t, err) return src } -// varsByName indexes a slice of variables by name for convenient assertions. func varsByName(vars []*variables.Variable) map[string]*variables.Variable { out := make(map[string]*variables.Variable, len(vars)) for _, v := range vars { @@ -65,9 +47,6 @@ func varsByName(vars []*variables.Variable) map[string]*variables.Variable { return out } -// TestConsulSource_Fetch_Integration exercises ConsulSource.Fetch against a real -// Consul KV store. A single Consul agent is shared across subtests, and each -// subtest uses a unique KV prefix to stay isolated. func TestConsulSource_Fetch_Integration(t *testing.T) { ci.Parallel(t) @@ -80,11 +59,11 @@ func TestConsulSource_Fetch_Integration(t *testing.T) { "name": {Name: "name", Type: cty.String}, } - t.Run("reads vars under prefix/pack-id", func(t *testing.T) { - srv.SetKVString(t, "p1/webapp/replicas", "3") - srv.SetKVString(t, "p1/webapp/region", "us-west-2") + t.Run("fetches typed variables from KV path", func(t *testing.T) { + srv.SetKVString(t, "deploy/webapp/replicas", "3") + srv.SetKVString(t, "deploy/webapp/region", "us-west-2") - src := newSourceForServer(t, srv, "p1", true) + src := newSourceForServer(t, srv, "deploy/webapp") vars, err := src.Fetch(t.Context(), packID, schema) must.NoError(t, err) must.Len(t, 2, vars) @@ -95,44 +74,37 @@ func TestConsulSource_Fetch_Integration(t *testing.T) { must.Eq(t, "us-west-2", got["region"].Value.AsString()) }) - t.Run("full-path mode does not append pack id", func(t *testing.T) { - srv.SetKVString(t, "p2/path/region", "eu-central-1") + t.Run("path is not modified", func(t *testing.T) { + srv.SetKVString(t, "ops/webapp/region", "eu-central-1") - src := newSourceForServer(t, srv, "p2/path", false) + src := newSourceForServer(t, srv, "ops/webapp") vars, err := src.Fetch(t.Context(), packID, schema) must.NoError(t, err) must.Len(t, 1, vars) must.Eq(t, "eu-central-1", varsByName(vars)["region"].Value.AsString()) }) - t.Run("variables not in schema are skipped", func(t *testing.T) { - srv.SetKVString(t, "p3/webapp/region", "us-east-1") - srv.SetKVString(t, "p3/webapp/not_in_pack", "ignored") + t.Run("keys not in pack schema are ignored", func(t *testing.T) { + srv.SetKVString(t, "staging/webapp/region", "us-east-1") + srv.SetKVString(t, "staging/webapp/not_in_pack", "ignored") - src := newSourceForServer(t, srv, "p3", true) + src := newSourceForServer(t, srv, "staging/webapp") vars, err := src.Fetch(t.Context(), packID, schema) must.NoError(t, err) must.Len(t, 1, vars) must.Eq(t, "region", string(vars[0].Name)) }) - t.Run("empty value for non-string var is skipped so default applies", func(t *testing.T) { - srv.SetKVString(t, "p4/webapp/replicas", "") // empty: can't decode into a number - srv.SetKVString(t, "p4/webapp/region", "us-west-1") + t.Run("empty value for non-string variable is an error", func(t *testing.T) { + srv.SetKVString(t, "prod/webapp/replicas", "") + srv.SetKVString(t, "prod/webapp/region", "us-west-1") - src := newSourceForServer(t, srv, "p4", true) - vars, err := src.Fetch(t.Context(), packID, schema) - must.NoError(t, err) - // replicas (number) with an empty value is skipped so the pack default - // applies; region (string) is still returned. The whole fetch must not - // fail just because one key is empty. - must.Len(t, 1, vars) - must.Eq(t, "region", string(vars[0].Name)) + src := newSourceForServer(t, srv, "prod/webapp") + _, err := src.Fetch(t.Context(), packID, schema) + must.ErrorContains(t, err, "empty Consul value") }) - t.Run("object var with optional attribute resolves via constraint type", func(t *testing.T) { - // object({name=string, port=optional(number)}) stored as a partial JSON - // document. Type has optional() stripped; ConstraintType preserves it. + t.Run("object with optional field missing is valid", func(t *testing.T) { objSchema := map[variables.ID]*variables.Variable{ "svc": { Name: "svc", @@ -146,9 +118,9 @@ func TestConsulSource_Fetch_Integration(t *testing.T) { ), }, } - srv.SetKVString(t, "p5/webapp/svc", `{"name":"api"}`) + srv.SetKVString(t, "services/webapp/svc", `{"name":"api"}`) - src := newSourceForServer(t, srv, "p5", true) + src := newSourceForServer(t, srv, "services/webapp") vars, err := src.Fetch(t.Context(), packID, objSchema) must.NoError(t, err) must.Len(t, 1, vars) @@ -156,9 +128,40 @@ func TestConsulSource_Fetch_Integration(t *testing.T) { must.True(t, vars[0].Value.GetAttr("port").IsNull()) }) - t.Run("no keys under prefix returns no variables", func(t *testing.T) { - src := newSourceForServer(t, srv, "p6-empty", true) - vars, err := src.Fetch(t.Context(), pack.ID("absent"), schema) + t.Run("bool variable is decoded from JSON", func(t *testing.T) { + boolSchema := map[variables.ID]*variables.Variable{ + "enabled": {Name: "enabled", Type: cty.Bool}, + } + srv.SetKVString(t, "config/webapp/enabled", "true") + + src := newSourceForServer(t, srv, "config/webapp") + vars, err := src.Fetch(t.Context(), packID, boolSchema) + must.NoError(t, err) + must.Len(t, 1, vars) + must.True(t, vars[0].Value.True()) + }) + + t.Run("malformed JSON for non-string variable is an error", func(t *testing.T) { + srv.SetKVString(t, "broken/webapp/replicas", "not-a-number") + + src := newSourceForServer(t, srv, "broken/webapp") + _, err := src.Fetch(t.Context(), packID, schema) + must.ErrorContains(t, err, "decoding Consul value") + }) + + t.Run("empty string value is kept for string variable", func(t *testing.T) { + srv.SetKVString(t, "defaults/webapp/name", "") + + src := newSourceForServer(t, srv, "defaults/webapp") + vars, err := src.Fetch(t.Context(), packID, schema) + must.NoError(t, err) + must.Len(t, 1, vars) + must.Eq(t, "", vars[0].Value.AsString()) + }) + + t.Run("path with no keys returns empty result", func(t *testing.T) { + src := newSourceForServer(t, srv, "empty/webapp") + vars, err := src.Fetch(t.Context(), packID, schema) must.NoError(t, err) must.Len(t, 0, vars) }) diff --git a/internal/pkg/variable/source/consul_source_test.go b/internal/pkg/variable/source/consul_source_test.go deleted file mode 100644 index 7c788e3f..00000000 --- a/internal/pkg/variable/source/consul_source_test.go +++ /dev/null @@ -1,116 +0,0 @@ -// Copyright IBM Corp. 2023, 2026 -// SPDX-License-Identifier: MPL-2.0 - -package source - -import ( - "testing" - - "github.com/hashicorp/consul/api" - "github.com/hashicorp/nomad/ci" - "github.com/shoenig/test/must" - "github.com/zclconf/go-cty/cty" -) - -// TestConsulSource_convertValueWithSchema verifies that raw Consul KV bytes are -// converted using the variable's declared type, rather than by guessing the -// type from the value. This is the behavior reviewers asked for: a value is -// only ever coerced into the type the pack actually declares. -func TestConsulSource_convertValueWithSchema(t *testing.T) { - ci.Parallel(t) - - // convertValueWithSchema uses no receiver state, so a zero-value source is - // enough to exercise it. - c := &ConsulSource{} - - t.Run("string type keeps raw bytes even when valid JSON", func(t *testing.T) { - ci.Parallel(t) - // A string variable must preserve the exact bytes, so a JSON document - // stored in Consul stays a string instead of being decoded. This is the - // case that broke when the source guessed the type. - val, err := c.convertValueWithSchema([]byte(`{"hello":"world"}`), cty.String) - must.NoError(t, err) - must.Eq(t, cty.String, val.Type()) - must.Eq(t, `{"hello":"world"}`, val.AsString()) - }) - - t.Run("number type parses JSON number", func(t *testing.T) { - ci.Parallel(t) - val, err := c.convertValueWithSchema([]byte("3"), cty.Number) - must.NoError(t, err) - must.Eq(t, cty.Number, val.Type()) - got, _ := val.AsBigFloat().Int64() - must.Eq(t, int64(3), got) - }) - - t.Run("bool type parses JSON boolean", func(t *testing.T) { - ci.Parallel(t) - val, err := c.convertValueWithSchema([]byte("true"), cty.Bool) - must.NoError(t, err) - must.True(t, val.True()) - }) - - t.Run("list type parses JSON array", func(t *testing.T) { - ci.Parallel(t) - val, err := c.convertValueWithSchema([]byte(`["dc1","dc2"]`), cty.List(cty.String)) - must.NoError(t, err) - must.Eq(t, cty.List(cty.String), val.Type()) - must.Eq(t, 2, val.LengthInt()) - }) - - t.Run("object with optional attribute omitted", func(t *testing.T) { - ci.Parallel(t) - // The pack declares object({name=string, port=optional(number)}). The - // ConstraintType preserves optional(), so a Consul value missing "port" - // must still convert, with port set to null. Converting against the - // plain Type (which strips optional) would fail with "attribute port is - // required". - constraint := cty.ObjectWithOptionalAttrs( - map[string]cty.Type{"name": cty.String, "port": cty.Number}, - []string{"port"}, - ) - val, err := c.convertValueWithSchema([]byte(`{"name":"api"}`), constraint) - must.NoError(t, err) - must.Eq(t, "api", val.GetAttr("name").AsString()) - must.True(t, val.GetAttr("port").IsNull()) - }) - - t.Run("type mismatch returns error", func(t *testing.T) { - ci.Parallel(t) - // A JSON string cannot be coerced into a number. - _, err := c.convertValueWithSchema([]byte(`"not-a-number"`), cty.Number) - must.ErrorContains(t, err, "type mismatch") - }) - - t.Run("invalid JSON for non-string type returns error", func(t *testing.T) { - ci.Parallel(t) - _, err := c.convertValueWithSchema([]byte("not json"), cty.Number) - must.ErrorContains(t, err, "not valid JSON") - }) -} - -// TestNewConsulSource verifies that the source is constructed with a unique, -// descriptive name and a normalized prefix, without making any network calls. -func TestNewConsulSource(t *testing.T) { - ci.Parallel(t) - - t.Run("name encodes address and trimmed prefix", func(t *testing.T) { - ci.Parallel(t) - cfg := api.DefaultConfig() - cfg.Address = "consul.example:8500" - - src, err := NewConsulSource(PriorityConsul, cfg, "/my/prefix/", true) - must.NoError(t, err) - must.Eq(t, PriorityConsul, src.Priority()) - // Leading/trailing slashes are trimmed, and the name embeds the address - // and prefix so multiple Consul sources never collide. - must.Eq(t, "consul(consul.example:8500:my/prefix)", src.Name()) - }) - - t.Run("nil config falls back to Consul defaults", func(t *testing.T) { - ci.Parallel(t) - src, err := NewConsulSource(PriorityConsul, nil, "prefix", false) - must.NoError(t, err) - must.NotNil(t, src) - }) -} From 9e24f45a5d33fe95c1ac670cebd0d4f1642a4d1f Mon Sep 17 00:00:00 2001 From: TIMMAREDDY DEEKSHITHA Date: Mon, 22 Jun 2026 23:00:51 +0530 Subject: [PATCH 3/3] redirect consul agent logs to /dev/null unless -v --- .../pkg/variable/source/consul_source_integration_test.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/pkg/variable/source/consul_source_integration_test.go b/internal/pkg/variable/source/consul_source_integration_test.go index cf9fdcab..70621fc5 100644 --- a/internal/pkg/variable/source/consul_source_integration_test.go +++ b/internal/pkg/variable/source/consul_source_integration_test.go @@ -4,6 +4,7 @@ package source import ( + "io" "testing" "github.com/hashicorp/consul/api" @@ -20,6 +21,10 @@ func startTestConsul(t *testing.T) *consultest.TestServer { srv, err := consultest.NewTestServerConfigT(t, func(c *consultest.TestServerConfig) { c.Peering = nil + if !testing.Verbose() { + c.Stdout = io.Discard + c.Stderr = io.Discard + } }) if err != nil { t.Fatalf("failed to start Consul test server: %v", err)