-
Notifications
You must be signed in to change notification settings - Fork 64
[Issue #473 - Subtask 2] Implement External Variable Sources (Consul KV, Vault KV, Nomad Variables) #896
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
DeekshithaTimmareddy
wants to merge
8
commits into
feature/external-variable-sources
Choose a base branch
from
feature/subtask2-parser-integration
base: feature/external-variable-sources
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
[Issue #473 - Subtask 2] Implement External Variable Sources (Consul KV, Vault KV, Nomad Variables) #896
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
351c22c
Add Consul variable source implementation
DeekshithaTimmareddy 6a81a6d
feat: implement Consul KV and Vault KV variable sources
DeekshithaTimmareddy 2be0951
feat: Add external variable sources
DeekshithaTimmareddy ce33d3e
Fix variable source implementations
DeekshithaTimmareddy c47eed9
Update CHANGELOG for external variable sources
DeekshithaTimmareddy 5f73449
test: Improve test cleanup and error handling
DeekshithaTimmareddy 32a4c20
ci: Add Consul and Vault service containers
DeekshithaTimmareddy c46f4ac
ci: Fix Vault health check
DeekshithaTimmareddy File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,122 @@ | ||
| // Copyright IBM Corp. 2023, 2026 | ||
| // SPDX-License-Identifier: MPL-2.0 | ||
|
|
||
| package source | ||
|
|
||
| import ( | ||
| "context" | ||
| "encoding/json" | ||
| "fmt" | ||
| "strings" | ||
|
|
||
| "github.com/hashicorp/consul/api" | ||
| "github.com/hashicorp/nomad-pack/sdk/pack" | ||
| "github.com/hashicorp/nomad-pack/sdk/pack/variables" | ||
| "github.com/zclconf/go-cty/cty" | ||
| ) | ||
|
|
||
| // ConsulSource fetches variables from Consul KV store. | ||
| // Variables are stored under a configurable prefix with the structure: | ||
| // {prefix}/{pack-id}/{variable-name} | ||
| type ConsulSource struct { | ||
| name string | ||
| priority int | ||
| client *api.Client | ||
| prefix string | ||
| } | ||
|
|
||
| // NewConsulSource creates a new Consul KV variable source. | ||
| // The config parameter can be nil to use default Consul configuration | ||
| // (which reads from CONSUL_HTTP_ADDR and CONSUL_HTTP_TOKEN env vars). | ||
| func NewConsulSource(priority int, config *api.Config, prefix string) (*ConsulSource, error) { | ||
| if config == nil { | ||
| config = api.DefaultConfig() | ||
| } | ||
|
|
||
| client, err := api.NewClient(config) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to create Consul client: %w", err) | ||
| } | ||
|
|
||
| return &ConsulSource{ | ||
| name: "consul", | ||
| priority: priority, | ||
| client: client, | ||
| prefix: strings.Trim(prefix, "/"), | ||
| }, nil | ||
| } | ||
|
|
||
| // Name returns the unique identifier for this source. | ||
| func (c *ConsulSource) Name() string { | ||
| return c.name | ||
| } | ||
|
|
||
| // Priority returns the precedence level (higher = higher priority). | ||
| func (c *ConsulSource) Priority() int { | ||
| return c.priority | ||
| } | ||
|
|
||
| // Fetch retrieves variables for the given pack from Consul KV. | ||
| // Variables are expected to be stored as JSON values that can be | ||
| // converted to cty.Value types. If a value is not valid JSON, | ||
| // it will be treated as a string. | ||
| func (c *ConsulSource) Fetch(ctx context.Context, packID pack.ID) ([]*variables.Variable, error) { | ||
| if err := ctx.Err(); err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| // Build KV path: prefix/packID/ | ||
| path := fmt.Sprintf("%s/%s/", c.prefix, packID) | ||
|
|
||
| // List all keys under this path | ||
| opts := api.QueryOptions{RequireConsistent: true} | ||
| pairs, _, err := c.client.KV().List(path, (&opts).WithContext(ctx)) | ||
|
|
||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to list Consul KV at %s: %w", path, err) | ||
| } | ||
|
|
||
| // If no keys found, return nil (not an error) | ||
| if len(pairs) == 0 { | ||
| return nil, nil | ||
| } | ||
|
|
||
| vars := make([]*variables.Variable, 0, len(pairs)) | ||
| for _, pair := range pairs { | ||
| // Extract variable name from key (remove prefix) | ||
| varName := strings.TrimPrefix(pair.Key, path) | ||
|
|
||
| // Skip if this is a directory (ends with /) | ||
| if strings.HasSuffix(varName, "/") { | ||
| continue | ||
| } | ||
|
|
||
| // Convert value to cty.Value | ||
| value, err := c.convertValue(pair.Value) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to convert value for %s: %w", varName, err) | ||
| } | ||
|
|
||
| vars = append(vars, &variables.Variable{ | ||
| Name: variables.ID(varName), | ||
| Value: value, | ||
| Type: value.Type(), | ||
| }) | ||
| } | ||
|
|
||
| return vars, nil | ||
| } | ||
|
|
||
| // convertValue converts a byte slice to a cty.Value. | ||
| // It first attempts to parse as JSON. If that fails, it treats the value as a string. | ||
| func (c *ConsulSource) convertValue(data []byte) (cty.Value, error) { | ||
| // Try to parse as JSON first | ||
| var v interface{} | ||
| if err := json.Unmarshal(data, &v); err != nil { | ||
| // If not valid JSON, treat as string | ||
| return cty.StringVal(string(data)), nil | ||
| } | ||
|
|
||
| // Convert JSON to cty.Value | ||
| return convertJSONToCty(v) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,185 @@ | ||
| // Copyright IBM Corp. 2023, 2026 | ||
| // SPDX-License-Identifier: MPL-2.0 | ||
|
|
||
| package source | ||
|
|
||
| import ( | ||
| "context" | ||
| "testing" | ||
|
|
||
| "github.com/hashicorp/consul/api" | ||
| "github.com/hashicorp/nomad-pack/sdk/pack" | ||
| "github.com/hashicorp/nomad-pack/sdk/pack/variables" | ||
| "github.com/shoenig/test/must" | ||
| "github.com/zclconf/go-cty/cty" | ||
| ) | ||
|
|
||
| // skipIfConsulUnavailable checks if Consul is available and skips the test if not. | ||
| func skipIfConsulUnavailable(t *testing.T) *api.Client { | ||
| if testing.Short() { | ||
| t.Skip("skipping integration test in short mode") | ||
| } | ||
|
|
||
| config := api.DefaultConfig() | ||
| client, err := api.NewClient(config) | ||
| if err != nil { | ||
| t.Fatalf("Consul client creation failed: %v", err) | ||
| } | ||
|
|
||
| // Verify Consul is reachable | ||
| _, err = client.Status().Leader() | ||
| if err != nil { | ||
| t.Fatalf("Consul not reachable: %v", err) | ||
| } | ||
|
|
||
| return client | ||
| } | ||
|
|
||
| func TestConsulSource_Fetch_Success(t *testing.T) { | ||
| client := skipIfConsulUnavailable(t) | ||
|
|
||
| // Create source | ||
| source, err := NewConsulSource(40, nil, "nomad-pack-test/vars") | ||
| must.NoError(t, err) | ||
|
|
||
| packID := pack.ID("test-pack") | ||
| kv := client.KV() | ||
|
|
||
| // Setup test data | ||
| testData := map[string]string{ | ||
| "nomad-pack-test/vars/test-pack/string_var": `"hello world"`, | ||
| "nomad-pack-test/vars/test-pack/number_var": `42`, | ||
| "nomad-pack-test/vars/test-pack/bool_var": `true`, | ||
| "nomad-pack-test/vars/test-pack/list_var": `["a", "b", "c"]`, | ||
| "nomad-pack-test/vars/test-pack/map_var": `{"key1": "value1", "key2": "value2"}`, | ||
| } | ||
|
|
||
| for key, value := range testData { | ||
| _, err := kv.Put(&api.KVPair{ | ||
| Key: key, | ||
| Value: []byte(value), | ||
| }, nil) | ||
| must.NoError(t, err) | ||
| } | ||
|
|
||
| // Cleanup | ||
| t.Cleanup(func() { | ||
| _, err := kv.DeleteTree("nomad-pack-test/vars/test-pack/", nil) | ||
| must.NoError(t, err) | ||
| }) | ||
|
|
||
| // Fetch variables | ||
| vars, err := source.Fetch(t.Context(), packID) | ||
| must.NoError(t, err) | ||
| must.Len(t, 5, vars) | ||
|
|
||
| // Verify each variable | ||
| varMap := make(map[string]*variables.Variable) | ||
| for _, v := range vars { | ||
| varMap[string(v.Name)] = v | ||
| } | ||
|
|
||
| // Check string_var | ||
| must.True(t, varMap["string_var"].Value.Equals(cty.StringVal("hello world")).True()) | ||
|
|
||
| // Check number_var | ||
| must.True(t, varMap["number_var"].Value.Equals(cty.NumberIntVal(42)).True()) | ||
|
|
||
| // Check bool_var | ||
| must.True(t, varMap["bool_var"].Value.Equals(cty.BoolVal(true)).True()) | ||
|
|
||
| // Check list_var | ||
| expectedList := cty.ListVal([]cty.Value{ | ||
| cty.StringVal("a"), | ||
| cty.StringVal("b"), | ||
| cty.StringVal("c"), | ||
| }) | ||
| must.True(t, varMap["list_var"].Value.Equals(expectedList).True()) | ||
|
|
||
| // Check map_var | ||
| expectedMap := cty.ObjectVal(map[string]cty.Value{ | ||
| "key1": cty.StringVal("value1"), | ||
| "key2": cty.StringVal("value2"), | ||
| }) | ||
| must.True(t, varMap["map_var"].Value.Equals(expectedMap).True()) | ||
| } | ||
|
|
||
| func TestConsulSource_Fetch_ContextCancellation(t *testing.T) { | ||
| skipIfConsulUnavailable(t) | ||
|
|
||
| source, err := NewConsulSource(40, nil, "nomad-pack-test/vars") | ||
| must.NoError(t, err) | ||
|
|
||
| // Create cancelled context | ||
| ctx, cancel := context.WithCancel(context.Background()) | ||
| cancel() | ||
|
|
||
| // Fetch should fail with context error | ||
| _, err = source.Fetch(ctx, pack.ID("test-pack")) | ||
| must.Error(t, err) | ||
| } | ||
|
|
||
| func TestConsulSource_Fetch_NonJSONValue(t *testing.T) { | ||
| client := skipIfConsulUnavailable(t) | ||
|
|
||
| source, err := NewConsulSource(40, nil, "nomad-pack-test/vars") | ||
| must.NoError(t, err) | ||
|
|
||
| packID := pack.ID("test-pack-plain") | ||
| kv := client.KV() | ||
|
|
||
| // Put non-JSON value (plain string) | ||
| _, err = kv.Put(&api.KVPair{ | ||
| Key: "nomad-pack-test/vars/test-pack-plain/plain_text", | ||
| Value: []byte("this is not json"), | ||
| }, nil) | ||
| must.NoError(t, err) | ||
|
|
||
| // Cleanup | ||
| t.Cleanup(func() { | ||
| _, err := kv.DeleteTree("nomad-pack-test/vars/test-pack-plain/", nil) | ||
| must.NoError(t, err) | ||
| }) | ||
|
|
||
| // Fetch should succeed and treat as string | ||
| vars, err := source.Fetch(t.Context(), packID) | ||
| must.NoError(t, err) | ||
| must.Len(t, 1, vars) | ||
| must.True(t, vars[0].Value.Equals(cty.StringVal("this is not json")).True()) | ||
| } | ||
|
|
||
| func TestConsulSource_WithRegistry(t *testing.T) { | ||
| client := skipIfConsulUnavailable(t) | ||
|
|
||
| packID := pack.ID("test-pack-registry") | ||
| kv := client.KV() | ||
|
|
||
| // Setup test data in Consul | ||
| _, err := kv.Put(&api.KVPair{ | ||
| Key: "nomad-pack-test/vars/test-pack-registry/consul_var", | ||
| Value: []byte(`"from-consul"`), | ||
| }, nil) | ||
| must.NoError(t, err) | ||
|
|
||
| // Cleanup | ||
| t.Cleanup(func() { | ||
| _, err := kv.DeleteTree("nomad-pack-test/vars/test-pack-registry/", nil) | ||
| must.NoError(t, err) | ||
| }) | ||
|
|
||
| // Create registry with Consul source | ||
| registry := NewRegistry() | ||
|
|
||
| consulSource, err := NewConsulSource(40, nil, "nomad-pack-test/vars") | ||
| must.NoError(t, err) | ||
|
|
||
| err = registry.Register(consulSource) | ||
| must.NoError(t, err) | ||
|
|
||
| // Resolve variables | ||
| vars, err := registry.Resolve(t.Context(), packID) | ||
| must.NoError(t, err) | ||
| must.Len(t, 1, vars) | ||
| must.Eq(t, "consul_var", string(vars[0].Name)) | ||
| must.True(t, vars[0].Value.Equals(cty.StringVal("from-consul")).True()) | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Where's this prefix value actually come from? Have we verified that Pack IDs compatible with Consul, Nomad, and Vault KV paths? (I know Nomad jobs can have pretty wild IDs with unicode and slashes and all kinds of stuff we regret... I don't recall what that looks like for Pack)
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here, the prefix comes from the user when they create a source and the prefix is configurable so users can organize their variables under different paths.
And variable source implementatios don't perform their own validation - they accept whatever Pack ID string is passed to them and construct paths accordingly. (After testing) all three sources are fully compatible with valid pack IDs
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure, but how? There's no design doc here so there's no indication to me how we intended to configure these sources.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK, I will document explaining the configuration and usage.