diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e8dcfb0..5da0570 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -17,17 +17,13 @@ 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: build + - 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 @@ -44,9 +40,5 @@ 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 8fdab75..fbac9ca 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -19,11 +19,6 @@ 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 776e227..bd19276 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -3,10 +3,9 @@ builds: - id: vals main: ./cmd/vals env: - - CGO_ENABLED=1 - - CC=musl-gcc + - CGO_ENABLED=0 ldflags: - - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -linkmode external -extldflags "-static -Wl,-unresolved-symbols=ignore-all" + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} goos: - darwin - linux diff --git a/Makefile b/Makefile index 436d253..b0ee1bf 100644 --- a/Makefile +++ b/Makefile @@ -1,22 +1,10 @@ Version := $(shell git describe --tags --dirty --always) PKGS := $(shell go list ./... | grep -v /vendor/) GitCommit := $(shell git rev-parse HEAD) -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 +LDFLAGS := "-X main.version=$(Version) -X main.commit=$(GitCommit)" build: - CGO_ENABLED=1 $(CC_FLAGS) go build -ldflags $(LDFLAGS) -o bin/vals ./cmd/vals + go build -ldflags $(LDFLAGS) -o bin/vals ./cmd/vals install: build mv bin/vals ~/bin/ @@ -24,7 +12,8 @@ install: build lint: golangci-lint run -v --out-format=github-actions + test: - CGO_ENABLED=1 $(CC_FLAGS) go test -ldflags=$(LDFLAGS) -v ${PKGS} -coverprofile cover.out -race -p=1 + go test -v ${PKGS} -coverprofile cover.out -race -p=1 go tool cover -func cover.out -.PHONY: test +.PHONY: test \ No newline at end of file diff --git a/README.md b/README.md index c91daa1..9f21940 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,6 @@ 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. @@ -207,53 +206,30 @@ Please see the [relevant unit test cases](https://github.com/helmfile/vals/blob/ ## Supported Backends -- [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) +- [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) 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. @@ -855,9 +831,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`. +### 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`. Environment variables: @@ -876,41 +852,11 @@ 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. @@ -934,7 +880,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 @@ -957,7 +903,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 efa5bf7..c66a94b 100644 --- a/go.mod +++ b/go.mod @@ -39,7 +39,6 @@ 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 @@ -50,7 +49,6 @@ 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 3d2bc3b..039adfb 100644 --- a/go.sum +++ b/go.sum @@ -138,8 +138,6 @@ 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= @@ -233,8 +231,6 @@ 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 deleted file mode 100644 index 022eeb2..0000000 --- a/pkg/providers/bitwardensecrets/bitwardensecrets.go +++ /dev/null @@ -1,146 +0,0 @@ -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 setValue(configValue string, envVariable string, defaultValue string) string { - if configValue != "" { - return configValue - } - - envValue := os.Getenv(envVariable) - if envValue != "" { - return envValue - } - - return defaultValue -} - -func New(l *log.Logger, cfg api.StaticConfig) *provider { - p := &provider{ - log: l, - } - - p.ApiUrl = setValue(cfg.String("api_url"), "BWS_API_URL", "https://api.bitwarden.com") - p.IdentityURL = setValue(cfg.String("identity_url"), "BWS_IDENTITY_URL", "https://identity.bitwarden.com") - p.AccessToken = setValue(cfg.String("access_token"), "BWS_ACCESS_TOKEN", "") - p.OrganizationID = setValue(cfg.String("organization_id"), "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 b75af12..98d4506 100644 --- a/pkg/stringprovider/stringprovider.go +++ b/pkg/stringprovider/stringprovider.go @@ -8,7 +8,6 @@ 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" @@ -80,8 +79,6 @@ 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 30b0e58..a76f158 100644 --- a/vals.go +++ b/vals.go @@ -24,7 +24,6 @@ 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" @@ -102,7 +101,6 @@ const ( ProviderHCPVaultSecrets = "hcpvaultsecrets" ProviderHttpJsonManager = "httpjson" ProviderBitwarden = "bw" - ProviderBitwardenSecrets = "bws" ) var ( @@ -279,9 +277,6 @@ 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 deleted file mode 100644 index b68f8c4..0000000 --- a/vals_bitwardensecrets_test.go +++ /dev/null @@ -1,77 +0,0 @@ -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) - } - } - }) - } -}