diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5da0570e..e8dcfb01 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -17,13 +17,17 @@ jobs: with: go-version-file: 'go.mod' cache: true - - name: build + - name: Install musl-gcc + run: | + sudo apt-get update + sudo apt-get install musl-tools -y + - name: build run: make build unit-test: runs-on: ubuntu-latest env: - SKIP_TESTS: true + SKIP_TESTS: true steps: - name: Create k8s Kind Cluster uses: helm/kind-action@v1.10.0 @@ -40,5 +44,9 @@ jobs: with: go-version-file: 'go.mod' cache: true + - name: Install musl-gcc + run: | + sudo apt-get update + sudo apt-get install musl-tools -y - name: test run: make test diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index fbac9ca3..8fdab757 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -19,6 +19,11 @@ jobs: uses: actions/setup-go@v5 with: go-version-file: 'go.mod' + - + name: Install musl-gcc + run: | + sudo apt-get update + sudo apt-get install musl-tools -y - name: Run GoReleaser uses: goreleaser/goreleaser-action@v6 diff --git a/.goreleaser.yml b/.goreleaser.yml index bd192769..776e2275 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -3,9 +3,10 @@ builds: - id: vals main: ./cmd/vals env: - - CGO_ENABLED=0 + - CGO_ENABLED=1 + - CC=musl-gcc ldflags: - - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -linkmode external -extldflags "-static -Wl,-unresolved-symbols=ignore-all" goos: - darwin - linux diff --git a/Makefile b/Makefile index b0ee1bf9..436d253c 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,22 @@ Version := $(shell git describe --tags --dirty --always) PKGS := $(shell go list ./... | grep -v /vendor/) GitCommit := $(shell git rev-parse HEAD) -LDFLAGS := "-X main.version=$(Version) -X main.commit=$(GitCommit)" +INITIAL_LDFLAGS := -X main.version=$(Version) -X main.commit=$(GitCommit) + +platform := $(shell uname -o) + +ifeq ($(platform),Darwin) + CC_FLAGS := CC=clang CXX=clang++ + LDFLAGS := '$(INITIAL_LDFLAGS)' +endif + +ifeq ($(platform),GNU/Linux) + CC_FLAGS := CC=musl-gcc + LDFLAGS := '$(INITIAL_LDFLAGS) -linkmode external -extldflags "-static -Wl,-unresolved-symbols=ignore-all"' +endif build: - go build -ldflags $(LDFLAGS) -o bin/vals ./cmd/vals + CGO_ENABLED=1 $(CC_FLAGS) go build -ldflags $(LDFLAGS) -o bin/vals ./cmd/vals install: build mv bin/vals ~/bin/ @@ -12,8 +24,7 @@ install: build lint: golangci-lint run -v --out-format=github-actions - test: - go test -v ${PKGS} -coverprofile cover.out -race -p=1 + CGO_ENABLED=1 $(CC_FLAGS) go test -ldflags=$(LDFLAGS) -v ${PKGS} -coverprofile cover.out -race -p=1 go tool cover -func cover.out -.PHONY: test \ No newline at end of file +.PHONY: test diff --git a/README.md b/README.md index 9f219408..c91daa11 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ It supports various backends including: - Conjur - HCP Vault Secrets - Bitwarden +- Bitwarden Secrets - HTTP JSON - Use `vals eval -f refs.yaml` to replace all the `ref`s in the file to actual values and secrets. @@ -206,30 +207,53 @@ Please see the [relevant unit test cases](https://github.com/helmfile/vals/blob/ ## Supported Backends -- [Vault](#vault) -- [AWS SSM Parameter Store](#aws-ssm-parameter-store) -- [AWS Secrets Manager](#aws-secrets-manager) -- [AWS S3](#aws-s3) -- [GCP Secrets Manager](#gcp-secrets-manager) -- [GCP KMS](#gcp-kms) -- [Google Sheets](#google-sheets) -- [Google GCS](#google-gcs) -- [SOPS](#sops) powered by [sops](https://github.com/getsops/sops) -- [Terraform (tfstate)](#terraform-tfstate) powered by [tfstate-lookup](https://github.com/fujiwara/tfstate-lookup) -- [Echo](#echo) -- [File](#file) -- [Azure Key Vault](#azure-key-vault) -- [EnvSubst](#envsubst) -- [GitLab](#gitlab) -- [1Password](#1password) -- [1Password Connect](#1password-connect) -- [Doppler](#doppler) -- [Pulumi State](#pulumi-state) -- [Kubernetes](#kubernetes) -- [Conjur](#conjur) -- [HCP Vault Secrets](#hcp-vault-secrets) -- [HTTP JSON](#http-json) -- [Bitwarden](#bitwarden) +- [vals](#vals) + - [Usage](#usage) +- [CLI](#cli) + - [Helm](#helm) + - [Go](#go) + - [Expression Syntax](#expression-syntax) + - [Supported Backends](#supported-backends) + - [Vault](#vault) + - [Authentication](#authentication) + - [AWS](#aws) + - [AWS SSM Parameter Store](#aws-ssm-parameter-store) + - [AWS Secrets Manager](#aws-secrets-manager) + - [AWS S3](#aws-s3) + - [AWS KMS](#aws-kms) + - [Google GCS](#google-gcs) + - [GCP Secrets Manager](#gcp-secrets-manager) + - [GCP KMS](#gcp-kms) + - [Google Sheets](#google-sheets) + - [Terraform (tfstate)](#terraform-tfstate) + - [Terraform in GCS bucket (tfstategs)](#terraform-in-gcs-bucket-tfstategs) + - [Terraform in S3 bucket (tfstates3)](#terraform-in-s3-bucket-tfstates3) + - [Terraform in AzureRM Blob storage (tfstateazurerm)](#terraform-in-azurerm-blob-storage-tfstateazurerm) + - [Terraform in Terraform Cloud / Terraform Enterprise (tfstateremote)](#terraform-in-terraform-cloud--terraform-enterprise-tfstateremote) + - [SOPS](#sops) + - [Echo](#echo) + - [File](#file) + - [Azure Key Vault](#azure-key-vault) + - [Authentication](#authentication-1) + - [EnvSubst](#envsubst) + - [GitLab Secrets](#gitlab-secrets) + - [1Password](#1password) + - [1Password Connect](#1password-connect) + - [Doppler](#doppler) + - [Pulumi State](#pulumi-state) + - [Kubernetes](#kubernetes) + - [Conjur](#conjur) + - [HCP Vault Secrets](#hcp-vault-secrets) + - [Bitwarden](#bitwarden) + - [Bitwarden Secrets](#bitwarden-secrets) + - [HTTP JSON](#http-json) + - [Fetch string value](#fetch-string-value) + - [Fetch integer value](#fetch-integer-value) + - [Advanced Usages](#advanced-usages) + - [Discriminating config and secrets](#discriminating-config-and-secrets) + - [Non-Goals](#non-goals) + - [Complex String-Interpolation / Template Functions](#complex-string-interpolation--template-functions) + - [Merge](#merge) Please see [pkg/providers](https://github.com/helmfile/vals/tree/master/pkg/providers) for the implementations of all the providers. The package names corresponds to the URI schemes. @@ -831,9 +855,9 @@ Example: `ref+hcpvaultsecrets://APPLICATION_NAME/SECRET_NAME[?client_id=HCP_CLIENT_ID&client_secret=HCP_CLIENT_SECRET&organization_id=HCP_ORGANIZATION_ID&organization_name=HCP_ORGANIZATION_NAME&project_id=HCP_PROJECT_ID&project_name=HCP_PROJECT_NAME&version=2]` - ### Bitwarden -This provider retrieves the secrets stored in Bitwarden. It uses the [Bitwarden Vault-Management API](https://bitwarden.com/help/vault-management-api/) that is included in the [Bitwarden CLI](https://github.com/bitwarden/clients) by executing `bw serve`. + +This provider retrieves the secrets stored in Bitwarden. It uses the [Bitwarden Vault-Management API](https://bitwarden.com/help/vault-management-api/) that is included in the [Bitwarden CLI](https://github.com/bitwarden/clients) by executing `bw serve`. Environment variables: @@ -852,11 +876,41 @@ Examples: - `ref+bw://4d084b01-87e7-4411-8de9-2476ab9f3f48/{username,password,uri,notes,item}` gets username, password, uri, notes or the whole item of the given item id - `ref+bw://4d084b01-87e7-4411-8de9-2476ab9f3f48/notes#/key1` gets the *key1* from the yaml stored as note in the item +### Bitwarden Secrets + +This provider retrieves the secrets stored in Bitwarden SECRETS MANAGER (not BitWarden Password Manager like [Bitwarden](#bitwarden)). + +It authenticates using an Access Token from a "Machine account", which can be created by opening the Bitwarden Web Vault, switching to Bitwarden `Secrets Manager`, then **Machine accounts**. Create a new machine account, grant at least "Can read" permission to a Project, and generate an Access Token. + +This provider also needs the `Organization ID` which is a UUID that you can find in the URL like: https://vault.bitwarden.com/#/sm/00000000-0000-0000-0000-000000000000, where 00000000-0000-0000-0000-000000000000 is the Organization ID. + +It is based on the [Bitwarden SDK in Go](https://github.com/bitwarden/sdk/tree/main/languages/go) lib. + +Environment variables: + +- `BWS_API_URL`: The Bitwarden API service endpoint, defaults to `https://api.bitwarden.com` +- `BWS_IDENTITY_URL`: The Bitwarden Identity service endpoint, defaults to `https://identity.bitwarden.com` +- `BWS_ACCESS_TOKEN`: The Bitwarden Access Token for the Machine Account +- `BWS_ORGANIZATION_ID`: The Bitwarden Organization ID + +Parameters: + +Parameters are optional and can be passed as query parameters in the URI, taking precedence over environment variables. + +- `api_url`: The Bitwarden API service endpoint, defaults to `https://api.bitwarden.com` +- `identity_url`: The Bitwarden Identity service endpoint, defaults to `https://identity.bitwarden.com` +- `access_token`: The Bitwarden Access Token for the Machine Account +- `organization_id`: The Bitwarden Organization ID + +Example: + +`ref+bws://PROJECT_NAME/SECRET_NAME[?api_url=BWS_API_URL&identity_url=BWS_IDENTITY_URL&access_token=BWS_ACCESS_TOKEN&organization_id=BWS_ORGANIZATION_ID]` + ### HTTP JSON This provider retrieves values stored in JSON hosted by a HTTP frontend. -This provider is built on top of [jsonquery](https://pkg.go.dev/github.com/antchfx/jsonquery@v1.3.3) and [xpath](https://pkg.go.dev/github.com/antchfx/xpath@v1.2.3) packages. +This provider is built on top of [jsonquery](https://pkg.go.dev/github.com/antchfx/jsonquery@v1.3.3) and [xpath](https://pkg.go.dev/github.com/antchfx/xpath@v1.2.3) packages. Given the diverse array of JSON structures that can be encountered, utilizing jsonquery with XPath presents a more effective approach for handling this variability in data structures. @@ -880,7 +934,7 @@ Let's say you want to fetch the below JSON object from https://api.github.com/us "name": "go-yaml" } ] -``` +``` ``` # To get name="chartify" using https protocol you would use: ref+httpjson://api.github.com/users/helmfile/repos#///*[1]/name @@ -903,7 +957,7 @@ Let's say you want to fetch the below JSON object from https://api.github.com/us "id": 251296379 } ] -``` +``` ``` # Running the following will return: 2.51296379e+08 ref+httpjson://api.github.com/users/helmfile/repos#///*[1]/id diff --git a/go.mod b/go.mod index c66a94b2..efa5bf7e 100644 --- a/go.mod +++ b/go.mod @@ -39,6 +39,7 @@ require ( cloud.google.com/go/auth/oauth2adapt v0.2.4 // indirect cloud.google.com/go/longrunning v0.6.0 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/bitwarden/sdk-go v1.0.0 // indirect github.com/extism/go-sdk v1.3.1 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/go-jose/go-jose/v4 v4.0.2 // indirect @@ -49,6 +50,7 @@ require ( github.com/go-openapi/strfmt v0.23.0 // indirect github.com/go-openapi/validate v0.24.0 // indirect github.com/gobwas/glob v0.2.3 // indirect + github.com/gofrs/uuid v4.4.0+incompatible // indirect github.com/google/go-jsonnet v0.20.0 // indirect github.com/hashicorp/go-version v1.6.0 // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect diff --git a/go.sum b/go.sum index 039adfbb..3d2bc3bc 100644 --- a/go.sum +++ b/go.sum @@ -138,6 +138,8 @@ github.com/aws/smithy-go v1.20.2 h1:tbp628ireGtzcHDDmLT/6ADHidqnwgF57XOXZe6tp4Q= github.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas= github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4= +github.com/bitwarden/sdk-go v1.0.0 h1:XupMlyu7CxdtKFi7obSknwICQ2J+WtmzndfVkkiGShI= +github.com/bitwarden/sdk-go v1.0.0/go.mod h1:RuYh+gqffp3h8wNUVWz1bvp2Pho10AFz+WIlI26iWY4= github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/cenkalti/backoff/v3 v3.2.2 h1:cfUAAO3yvKMYKPrvhDuHSwQnhZNk/RMHKdZqKTxfm6M= @@ -231,6 +233,8 @@ github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= +github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= diff --git a/pkg/providers/bitwardensecrets/bitwardensecrets.go b/pkg/providers/bitwardensecrets/bitwardensecrets.go new file mode 100644 index 00000000..f991ec47 --- /dev/null +++ b/pkg/providers/bitwardensecrets/bitwardensecrets.go @@ -0,0 +1,158 @@ +package bitwardensecrets + +import ( + "fmt" + "os" + "strings" + + sdk "github.com/bitwarden/sdk-go" + "github.com/gofrs/uuid" + "gopkg.in/yaml.v3" + + "github.com/helmfile/vals/pkg/api" + "github.com/helmfile/vals/pkg/log" +) + +type provider struct { + log *log.Logger + + ApiUrl string + IdentityURL string + AccessToken string + OrganizationID string + OrganizationUUID uuid.UUID + ProjectName string + ProjectID string +} + +func New(l *log.Logger, cfg api.StaticConfig) *provider { + p := &provider{ + log: l, + } + + p.ApiUrl = cfg.String("api_url") + if p.ApiUrl == "" { + apiUrl := os.Getenv("BWS_API_URL") + if apiUrl != "" { + p.ApiUrl = apiUrl + } else { + p.ApiUrl = "https://api.bitwarden.com" + } + } + + p.IdentityURL = cfg.String("identity_url") + if p.IdentityURL == "" { + identityUrl := os.Getenv("BWS_IDENTITY_URL") + if identityUrl != "" { + p.IdentityURL = identityUrl + } else { + p.IdentityURL = "https://identity.bitwarden.com" + } + } + + p.AccessToken = cfg.String("access_token") + if p.AccessToken == "" { + p.AccessToken = os.Getenv("BWS_ACCESS_TOKEN") + } + + p.OrganizationID = cfg.String("organization_id") + if p.OrganizationID == "" { + p.OrganizationID = os.Getenv("BWS_ORGANIZATION_ID") + } + + if p.AccessToken == "" || p.OrganizationID == "" { + p.log.Debugf("bitwardensecrets: access_token and organization_id are required") + } + + return p +} + +func (p *provider) GetString(key string) (string, error) { + spec, err := parseKey(key) + if err != nil { + return "", err + } + + bitwardenClient, _ := sdk.NewBitwardenClient(&p.ApiUrl, &p.IdentityURL) + + err = bitwardenClient.AccessTokenLogin(p.AccessToken, nil) + if err != nil { + return "", err + } + + p.OrganizationUUID, err = uuid.FromString(p.OrganizationID) + if err != nil { + return "", err + } + + projectList, err := bitwardenClient.Projects().List(p.OrganizationUUID.String()) + if err != nil { + return "", err + } + for _, project := range projectList.Data { + if project.Name == spec.projectName { + p.ProjectID = project.ID + } + } + + secretsList, err := bitwardenClient.Secrets().List(p.OrganizationUUID.String()) + if err != nil { + return "", err + } + var value string + for _, secret := range secretsList.Data { + if secret.Key == spec.secretName { + s, err := bitwardenClient.Secrets().Get(secret.ID) + if err != nil { + return "", err + } + if *s.ProjectID == p.ProjectID { + value = s.Value + } + } + } + + return value, nil +} + +type secretSpec struct { + projectName string + secretName string +} + +func parseKey(key string) (spec secretSpec, err error) { + // key should be in the format / + components := strings.Split(strings.TrimSuffix(key, "/"), "/") + if len(components) != 2 { + err = fmt.Errorf("invalid secret specifier: %q", key) + return + } + + if strings.TrimSpace(components[0]) == "" { + err = fmt.Errorf("missing key application name: %q", key) + return + } + + if strings.TrimSpace(components[1]) == "" { + err = fmt.Errorf("missing secret name: %q", key) + return + } + + spec.projectName = components[0] + spec.secretName = components[1] + return +} + +func (p *provider) GetStringMap(key string) (map[string]interface{}, error) { + m := map[string]interface{}{} + yamlStr, err := p.GetString(key) + if err != nil { + return nil, err + } + + err = yaml.Unmarshal([]byte(yamlStr), &m) + if err != nil { + return nil, fmt.Errorf("error while parsing secret for key %q as yaml: %v", key, err) + } + return m, nil +} diff --git a/pkg/stringprovider/stringprovider.go b/pkg/stringprovider/stringprovider.go index 98d4506f..b75af127 100644 --- a/pkg/stringprovider/stringprovider.go +++ b/pkg/stringprovider/stringprovider.go @@ -8,6 +8,7 @@ import ( "github.com/helmfile/vals/pkg/providers/awskms" "github.com/helmfile/vals/pkg/providers/awssecrets" "github.com/helmfile/vals/pkg/providers/azurekeyvault" + "github.com/helmfile/vals/pkg/providers/bitwardensecrets" "github.com/helmfile/vals/pkg/providers/conjur" "github.com/helmfile/vals/pkg/providers/doppler" "github.com/helmfile/vals/pkg/providers/gcpsecrets" @@ -79,6 +80,8 @@ func New(l *log.Logger, provider api.StaticConfig) (api.LazyLoadedStringProvider return hcpvaultsecrets.New(l, provider), nil case "httpjson": return httpjson.New(l, provider), nil + case "bws": + return bitwardensecrets.New(l, provider), nil } return nil, fmt.Errorf("failed initializing string provider from config: %v", provider) diff --git a/vals.go b/vals.go index a76f158a..30b0e581 100644 --- a/vals.go +++ b/vals.go @@ -24,6 +24,7 @@ import ( "github.com/helmfile/vals/pkg/providers/awssecrets" "github.com/helmfile/vals/pkg/providers/azurekeyvault" "github.com/helmfile/vals/pkg/providers/bitwarden" + "github.com/helmfile/vals/pkg/providers/bitwardensecrets" "github.com/helmfile/vals/pkg/providers/conjur" "github.com/helmfile/vals/pkg/providers/doppler" "github.com/helmfile/vals/pkg/providers/echo" @@ -101,6 +102,7 @@ const ( ProviderHCPVaultSecrets = "hcpvaultsecrets" ProviderHttpJsonManager = "httpjson" ProviderBitwarden = "bw" + ProviderBitwardenSecrets = "bws" ) var ( @@ -277,6 +279,9 @@ func (r *Runtime) prepare() (*expansion.ExpandRegexMatch, error) { case ProviderBitwarden: p := bitwarden.New(r.logger, conf) return p, nil + case ProviderBitwardenSecrets: + p := bitwardensecrets.New(r.logger, conf) + return p, nil } return nil, fmt.Errorf("no provider registered for scheme %q", scheme) } diff --git a/vals_bitwardensecrets_test.go b/vals_bitwardensecrets_test.go new file mode 100644 index 00000000..b68f8c49 --- /dev/null +++ b/vals_bitwardensecrets_test.go @@ -0,0 +1,77 @@ +package vals + +import ( + "fmt" + "os" + "testing" + + config2 "github.com/helmfile/vals/pkg/config" +) + +func TestValues_BitwardenSecrets_String(t *testing.T) { + // TODO + // Pre-requisite: + // 1. Create a Project in Bitwarden Secrets Manager called "vals-test" + // 2. Create a "Machine account", grant "Can read" permission on the "vals-test" project, and generate an access token + // 3. Get the Organization ID from the Bitwarden Secrets Manager URL. For example, if the URL is https://vault.bitwarden.com/#/sm/00000000-0000-0000-0000-000000000000, the Organization ID is 00000000-0000-0000-0000-000000000000. + // 4. Export the following environment variables: + // - BWS_ACCESS_TOKEN + // - BWS_ORGANIZATION_ID + // 5. Create a secret in the "vals-test" project with name "fooKey" and value "myValue" + + if os.Getenv("SKIP_TESTS") != "" { + t.Skip("Skipping tests") + } + + type testcase struct { + config map[string]interface{} + } + + commonInline := map[string]interface{}{ + "vals-test": "fooKey", + } + + testcases := []testcase{ + { + config: map[string]interface{}{ + "provider": map[string]interface{}{ + "name": "bws", + "type": "string", + "path": "vals-test", + }, + "inline": commonInline, + }, + }, + { + config: map[string]interface{}{ + "provider": map[string]interface{}{ + "name": "bws", + // implies type=string + "path": "vals-test", + }, + "inline": commonInline, + }, + }, + } + + for i := range testcases { + tc := testcases[i] + t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { + config := config2.Map(tc.config) + + vals, err := Load(config) + if err != nil { + t.Fatalf("failed to load config for testcase %d: %v", i, err) + } + + { + expected := "myValue" + key := "vals-test" + actual := vals[key] + if actual != expected { + t.Errorf("unepected value for key %q: expected=%q, got=%q", key, expected, actual) + } + } + }) + } +}