diff --git a/.github/workflows/govulncheck.yml b/.github/workflows/govulncheck.yml index 7d56b139..f546c94b 100644 --- a/.github/workflows/govulncheck.yml +++ b/.github/workflows/govulncheck.yml @@ -21,7 +21,7 @@ jobs: - name: Setup Go uses: actions/setup-go@7b8cf10d4e4a01d4992d18a89f4d7dc5a3e6d6f4 # v4.3.0 with: - go-version: "1.25.12" + go-version: "1.26.6" cache: true cache-dependency-path: go.sum - name: Install govulncheck diff --git a/.github/workflows/release_build_infisical_cli.yml b/.github/workflows/release_build_infisical_cli.yml index 59ee309a..b7558c61 100644 --- a/.github/workflows/release_build_infisical_cli.yml +++ b/.github/workflows/release_build_infisical_cli.yml @@ -167,7 +167,7 @@ jobs: - run: echo "Ref name ${{github.ref_name}}" - uses: actions/setup-go@be3c94b385c4f180051c996d336f57a34c397495 # v3.6.1 with: - go-version: "1.25.12" + go-version: "1.26.6" cache: true cache-dependency-path: go.sum - name: Setup for libssl1.0-dev @@ -394,7 +394,7 @@ jobs: - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: - go-version: "1.25.12" + go-version: "1.26.6" cache: true cache-dependency-path: go.sum @@ -474,7 +474,7 @@ jobs: - name: Set up Go uses: actions/setup-go@be3c94b385c4f180051c996d336f57a34c397495 # v3.6.1 with: - go-version: "1.25.12" + go-version: "1.26.6" cache: true cache-dependency-path: go.sum diff --git a/.github/workflows/run-cli-e2e-tests.yml b/.github/workflows/run-cli-e2e-tests.yml index 22a92e34..8f1a7ed3 100644 --- a/.github/workflows/run-cli-e2e-tests.yml +++ b/.github/workflows/run-cli-e2e-tests.yml @@ -16,7 +16,7 @@ jobs: - name: Setup Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: "1.25.12" + go-version: "1.26.6" - name: Install dependencies run: go get . - name: Build the CLI @@ -52,7 +52,7 @@ jobs: - name: Setup Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: "1.25.12" + go-version: "1.26.6" - name: Install dependencies run: go get . - name: Build the CLI @@ -87,7 +87,7 @@ jobs: - name: Setup Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: "1.25.12" + go-version: "1.26.6" - name: Install dependencies run: go get . - name: Cache cargo registry + target diff --git a/.github/workflows/run-cli-smoke-tests.yml b/.github/workflows/run-cli-smoke-tests.yml index abccaeca..9c6e5dee 100644 --- a/.github/workflows/run-cli-smoke-tests.yml +++ b/.github/workflows/run-cli-smoke-tests.yml @@ -25,7 +25,7 @@ jobs: - name: Set up Go uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: - go-version: "1.25.12" + go-version: "1.26.6" - name: Cache cargo registry + target uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 diff --git a/.github/workflows/run-cli-tests.yml b/.github/workflows/run-cli-tests.yml index 3814a3a9..b90a414b 100644 --- a/.github/workflows/run-cli-tests.yml +++ b/.github/workflows/run-cli-tests.yml @@ -35,7 +35,7 @@ jobs: - name: Setup Go uses: actions/setup-go@7b8cf10d4e4a01d4992d18a89f4d7dc5a3e6d6f4 # v4.3.0 with: - go-version: "1.25.12" + go-version: "1.26.6" - name: Install dependencies run: go get . - name: Test with the Go CLI diff --git a/.github/workflows/test-update-instructions.yml b/.github/workflows/test-update-instructions.yml index b969535d..afa3b638 100644 --- a/.github/workflows/test-update-instructions.yml +++ b/.github/workflows/test-update-instructions.yml @@ -11,7 +11,7 @@ jobs: - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - uses: actions/setup-go@7b8cf10d4e4a01d4992d18a89f4d7dc5a3e6d6f4 # v4.3.0 with: - go-version: "1.25.12" + go-version: "1.26.6" - name: Run unit tests run: go test ./packages/util/ -run TestGetUpdateInstructions -v @@ -21,7 +21,7 @@ jobs: - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - uses: actions/setup-go@7b8cf10d4e4a01d4992d18a89f4d7dc5a3e6d6f4 # v4.3.0 with: - go-version: "1.25.12" + go-version: "1.26.6" - name: Build test helper run: go build -o update-hint.exe ./test/update-hint @@ -70,7 +70,7 @@ jobs: - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - uses: actions/setup-go@7b8cf10d4e4a01d4992d18a89f4d7dc5a3e6d6f4 # v4.3.0 with: - go-version: "1.25.12" + go-version: "1.26.6" - name: Build test helper run: go build -o update-hint ./test/update-hint @@ -97,7 +97,7 @@ jobs: - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - uses: actions/setup-go@7b8cf10d4e4a01d4992d18a89f4d7dc5a3e6d6f4 # v4.3.0 with: - go-version: "1.25.12" + go-version: "1.26.6" - name: Build test helper run: go build -o update-hint ./test/update-hint diff --git a/e2e/go.mod b/e2e/go.mod index 81c1389c..75ff6581 100644 --- a/e2e/go.mod +++ b/e2e/go.mod @@ -1,6 +1,6 @@ module github.com/infisical/cli/e2e-tests -go 1.25.12 +go 1.26.6 require ( github.com/Infisical/infisical-merge v0.0.0 @@ -168,7 +168,7 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/indece-official/go-ebcdic v1.2.0 // indirect github.com/infisical/go-sdk v0.7.0 // indirect - github.com/infisical/infisical-kmip v0.3.19 // indirect + github.com/infisical/infisical-kmip v0.3.20-0.20260808014015-39f64ec76f78 // indirect github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect diff --git a/e2e/go.sum b/e2e/go.sum index 04c3e52c..50710085 100644 --- a/e2e/go.sum +++ b/e2e/go.sum @@ -591,8 +591,8 @@ github.com/indece-official/go-ebcdic v1.2.0 h1:nKCubkNoXrGvBp3MSYuplOQnhANCDEY51 github.com/indece-official/go-ebcdic v1.2.0/go.mod h1:RBddVJt0Ks0eDLRG5dhPwBDRiTNA7n+yv0dVFpSs46Q= github.com/infisical/go-sdk v0.7.0 h1:x9/1PczL+ioVD1jCp4LHQzPpBawatbmkjAC0S1OAtUA= github.com/infisical/go-sdk v0.7.0/go.mod h1:yEfXF+3YDDXiJ9zzJUSzW6me6XXPPEDK52fSU6JfpCA= -github.com/infisical/infisical-kmip v0.3.19 h1:JDndxhM9+GoHTqSOX47H3KIxIuDmrEHsq17wvhw3RL8= -github.com/infisical/infisical-kmip v0.3.19/go.mod h1:bO1M4YtKyutNg1bREPmlyZspC5duSR7hyQ3lPmLzrIs= +github.com/infisical/infisical-kmip v0.3.20-0.20260808014015-39f64ec76f78 h1:p145uaPRke7N1kP5lMcO/XX/OOosHhQ2JS3095aQVhM= +github.com/infisical/infisical-kmip v0.3.20-0.20260808014015-39f64ec76f78/go.mod h1:bO1M4YtKyutNg1bREPmlyZspC5duSR7hyQ3lPmLzrIs= github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf h1:FtEj8sfIcaaBfAKrE1Cwb61YDtYq9JxChK1c7AKce7s= github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf/go.mod h1:yrqSXGoD/4EKfF26AOGzscPOgTTJcyAwM2rpixWT+t4= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= diff --git a/go.mod b/go.mod index bc4dc53d..b9490509 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/Infisical/infisical-merge -go 1.25.12 +go 1.26.6 require ( github.com/Azure/go-ntlmssp v0.1.1 @@ -23,7 +23,7 @@ require ( github.com/google/uuid v1.6.0 github.com/h2non/filetype v1.1.3 github.com/infisical/go-sdk v0.7.0 - github.com/infisical/infisical-kmip v0.3.19 + github.com/infisical/infisical-kmip v0.3.20-0.20260808014015-39f64ec76f78 github.com/jackc/pgx/v5 v5.9.2 github.com/jcmturner/gokrb5/v8 v8.4.4 github.com/masterzen/winrm v0.0.0-20260407182533-5570be7f80cf diff --git a/go.sum b/go.sum index 3ba40cf0..be0e57ea 100644 --- a/go.sum +++ b/go.sum @@ -416,8 +416,8 @@ github.com/indece-official/go-ebcdic v1.2.0 h1:nKCubkNoXrGvBp3MSYuplOQnhANCDEY51 github.com/indece-official/go-ebcdic v1.2.0/go.mod h1:RBddVJt0Ks0eDLRG5dhPwBDRiTNA7n+yv0dVFpSs46Q= github.com/infisical/go-sdk v0.7.0 h1:x9/1PczL+ioVD1jCp4LHQzPpBawatbmkjAC0S1OAtUA= github.com/infisical/go-sdk v0.7.0/go.mod h1:yEfXF+3YDDXiJ9zzJUSzW6me6XXPPEDK52fSU6JfpCA= -github.com/infisical/infisical-kmip v0.3.19 h1:JDndxhM9+GoHTqSOX47H3KIxIuDmrEHsq17wvhw3RL8= -github.com/infisical/infisical-kmip v0.3.19/go.mod h1:bO1M4YtKyutNg1bREPmlyZspC5duSR7hyQ3lPmLzrIs= +github.com/infisical/infisical-kmip v0.3.20-0.20260808014015-39f64ec76f78 h1:p145uaPRke7N1kP5lMcO/XX/OOosHhQ2JS3095aQVhM= +github.com/infisical/infisical-kmip v0.3.20-0.20260808014015-39f64ec76f78/go.mod h1:bO1M4YtKyutNg1bREPmlyZspC5duSR7hyQ3lPmLzrIs= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= diff --git a/packages/cmd/kmip.go b/packages/cmd/kmip.go index 49d33051..51664d07 100644 --- a/packages/cmd/kmip.go +++ b/packages/cmd/kmip.go @@ -4,11 +4,13 @@ Copyright (c) 2023 Infisical Inc. package cmd import ( + "context" "errors" "fmt" "os" "os/exec" "runtime" + "time" "github.com/Infisical/infisical-merge/packages/api" "github.com/Infisical/infisical-merge/packages/config" @@ -128,7 +130,7 @@ func startKmipServer(cmd *cobra.Command, args []string) { isResourceAuth := enrollMethod == localkmip.EnrollMethodToken || enrollMethod == localkmip.EnrollMethodAws if isResourceAuth { - serverConfig.AccessToken = enrollKmipServer(cmd, enrollMethod, serverName) + serverConfig.AccessToken, serverConfig.RefreshAccessToken = enrollKmipServer(cmd, enrollMethod, serverName) } else { // No enroll method given: reuse the stored enrollment access token unless explicit identity // credentials were passed. This keeps a manual restart of an enrolled server from silently @@ -138,6 +140,9 @@ func startKmipServer(cmd *cobra.Command, args []string) { if storedToken, _ := localkmip.LoadStoredAccessToken(serverName); storedToken != "" { log.Info().Msg("Using stored KMIP server access token") serverConfig.AccessToken = storedToken + if serverID, canRefresh := localkmip.ResolveAwsRefreshServerID(serverName); canRefresh { + serverConfig.RefreshAccessToken = newAwsRefreshAccessTokenFunc(serverName, serverID) + } } } if serverConfig.AccessToken == "" { @@ -148,9 +153,38 @@ func startKmipServer(cmd *cobra.Command, args []string) { kmip.StartServer(serverConfig) } +// newAwsRefreshAccessTokenFunc returns a function that re-authenticates the KMIP server via +// AWS STS and persists the new access token, used by the renewal loop when the current token +// is rejected. Bounded timeouts so a hung API call cannot stall renewal indefinitely. +func newAwsRefreshAccessTokenFunc(serverName, kmipServerID string) func() (string, error) { + return func() (string, error) { + refreshClient, err := util.GetRestyClientWithCustomHeaders() + if err != nil { + return "", fmt.Errorf("unable to create HTTP client: %w", err) + } + refreshClient.SetTimeout(30 * time.Second) + + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + newToken, err := localkmip.LoginKmipServerWithAws(ctx, refreshClient, kmipServerID) + if err != nil { + return "", fmt.Errorf("AWS Auth re-login failed: %w", err) + } + + if err := localkmip.SaveAccessToken(serverName, newToken); err != nil { + log.Warn().Msgf("failed to persist refreshed KMIP access token: %v", err) + } + + return newToken, nil + } +} + // enrollKmipServer obtains a KMIP server access token via token or AWS enrollment, -// persisting the relevant state under the KMIP server's config file. -func enrollKmipServer(cmd *cobra.Command, enrollMethod, serverName string) string { +// persisting the relevant state under the KMIP server's config file. For AWS enrollment it +// also returns a refresh function so the server can re-authenticate when its access token +// expires before its certificate does; token enrollment is single-use, so no refresh exists. +func enrollKmipServer(cmd *cobra.Command, enrollMethod, serverName string) (string, func() (string, error)) { httpClient, err := util.GetRestyClientWithCustomHeaders() if err != nil { util.HandleError(err, "unable to create HTTP client") @@ -178,12 +212,16 @@ func enrollKmipServer(cmd *cobra.Command, enrollMethod, serverName string) strin if err := localkmip.SaveServerID(serverName, kmipServerID); err != nil { util.HandleError(err, "failed to save KMIP server id to config") } + if err := localkmip.SaveEnrollMethod(serverName, localkmip.EnrollMethodAws); err != nil { + util.HandleError(err, "failed to save KMIP enroll method to config") + } if err := localkmip.SaveDomain(serverName, config.INFISICAL_URL); err != nil { util.HandleError(err, "failed to save domain to config") } log.Info().Msgf("KMIP server authenticated via AWS Auth. State saved to %s", localkmip.GetConfPathDisplay(serverName)) - return accessToken + + return accessToken, newAwsRefreshAccessTokenFunc(serverName, kmipServerID) } // Enrollment token path @@ -197,7 +235,7 @@ func enrollKmipServer(cmd *cobra.Command, enrollMethod, serverName string) strin storedAccessToken, err := localkmip.LoadStoredAccessToken(serverName) if err == nil && storedAccessToken != "" { log.Info().Msg("Reusing stored KMIP server access token.") - return storedAccessToken + return storedAccessToken, nil } if enrollToken == "" { util.HandleError(errors.New("--token is required when --enroll-method=token and no access token is stored")) @@ -220,6 +258,9 @@ func enrollKmipServer(cmd *cobra.Command, enrollMethod, serverName string) strin if err := localkmip.SaveAccessToken(serverName, enrollResp.AccessToken); err != nil { util.HandleError(err, "failed to save KMIP server access token") } + if err := localkmip.SaveEnrollMethod(serverName, localkmip.EnrollMethodToken); err != nil { + util.HandleError(err, "failed to save KMIP enroll method to config") + } if err := localkmip.SaveEnrollmentToken(serverName, enrollToken); err != nil { util.HandleError(err, "failed to save enrollment token to config") } @@ -228,7 +269,7 @@ func enrollKmipServer(cmd *cobra.Command, enrollMethod, serverName string) strin } log.Info().Msgf("KMIP server enrolled successfully. Access token saved to %s", localkmip.GetConfPathDisplay(serverName)) - return enrollResp.AccessToken + return enrollResp.AccessToken, nil } // resolveKmipIdentityCredentials parses the legacy machine-identity credentials. diff --git a/packages/kmip/enroll.go b/packages/kmip/enroll.go index f37318c3..ee752ad5 100644 --- a/packages/kmip/enroll.go +++ b/packages/kmip/enroll.go @@ -131,6 +131,31 @@ func SaveServerID(name, kmipServerID string) error { return saveConfKey(name, INFISICAL_KMIP_SERVER_ID_KEY, kmipServerID) } +// Conf-file only: the env var says what this run asked for, not how the server enrolled. +func LoadStoredEnrollMethod(name string) (string, error) { + return loadConfKey(name, INFISICAL_KMIP_ENROLL_METHOD_KEY) +} + +func SaveEnrollMethod(name, method string) error { + return saveConfKey(name, INFISICAL_KMIP_ENROLL_METHOD_KEY, method) +} + +func LoadPersistedServerID(name string) (string, error) { + return loadConfKey(name, INFISICAL_KMIP_SERVER_ID_KEY) +} + +// Ignores the environment throughout: honouring INFISICAL_KMIP_SERVER_ID here would wire a +// token-enrolled server for an STS refresh it cannot perform. A missing method means the server +// enrolled before it was recorded, where a conf-file server ID identifies AWS enrollment. +func ResolveAwsRefreshServerID(name string) (string, bool) { + method, _ := LoadStoredEnrollMethod(name) + serverID, _ := LoadPersistedServerID(name) + if serverID == "" { + return "", false + } + return serverID, method == EnrollMethodAws || method == "" +} + func GetConfPathDisplay(name string) string { path, err := kmipConfPath(name) if err != nil { diff --git a/packages/kmip/enroll_test.go b/packages/kmip/enroll_test.go new file mode 100644 index 00000000..2af9abcb --- /dev/null +++ b/packages/kmip/enroll_test.go @@ -0,0 +1,149 @@ +package kmip + +import "os" +import "testing" + +func cleanup(t *testing.T, name string) { + t.Helper() + if p, err := kmipConfPath(name); err == nil { + _ = os.Remove(p) + } +} + +func TestAwsRefreshOnlyForAwsEnrolledServers(t *testing.T) { + t.Run("token enrolled with env server id", func(t *testing.T) { + name := "probe-token" + defer cleanup(t, name) + if err := SaveEnrollMethod(name, EnrollMethodToken); err != nil { + t.Fatal(err) + } + t.Setenv(INFISICAL_KMIP_SERVER_ID_KEY, "srv-from-environment") + if _, aws := ResolveAwsRefreshServerID(name); aws { + t.Fatal("token-enrolled server must not be wired for STS refresh") + } + }) + + t.Run("aws enrolled", func(t *testing.T) { + name := "probe-aws" + defer cleanup(t, name) + if err := SaveEnrollMethod(name, EnrollMethodAws); err != nil { + t.Fatal(err) + } + if err := SaveServerID(name, "srv-real"); err != nil { + t.Fatal(err) + } + id, aws := ResolveAwsRefreshServerID(name) + if !aws || id != "srv-real" { + t.Fatalf("aws-enrolled server should refresh via STS, got id=%q aws=%v", id, aws) + } + }) + + t.Run("legacy aws enrolled without a recorded method", func(t *testing.T) { + name := "probe-legacy" + defer cleanup(t, name) + if err := SaveServerID(name, "srv-legacy"); err != nil { + t.Fatal(err) + } + id, aws := ResolveAwsRefreshServerID(name) + if !aws || id != "srv-legacy" { + t.Fatalf("legacy aws server should still refresh, got id=%q aws=%v", id, aws) + } + }) + + t.Run("legacy token enrolled with env server id", func(t *testing.T) { + name := "probe-legacy-token" + defer cleanup(t, name) + if err := SaveEnrollmentToken(name, "tok"); err != nil { + t.Fatal(err) + } + t.Setenv(INFISICAL_KMIP_SERVER_ID_KEY, "srv-from-environment") + if _, aws := ResolveAwsRefreshServerID(name); aws { + t.Fatal("env var must not make a legacy token-enrolled server look aws-enrolled") + } + }) +} + +func TestResolveAwsRefreshServerIDEdgeCases(t *testing.T) { + t.Run("never enrolled", func(t *testing.T) { + name := "probe-absent" + defer cleanup(t, name) + if _, aws := ResolveAwsRefreshServerID(name); aws { + t.Fatal("a server with no config must not refresh") + } + }) + + t.Run("aws method recorded but no server id", func(t *testing.T) { + name := "probe-aws-no-id" + defer cleanup(t, name) + if err := SaveEnrollMethod(name, EnrollMethodAws); err != nil { + t.Fatal(err) + } + if _, aws := ResolveAwsRefreshServerID(name); aws { + t.Fatal("there is nothing to re-authenticate with without a server id") + } + }) + + t.Run("stale server id from a previous aws enrollment", func(t *testing.T) { + name := "probe-reenrolled" + defer cleanup(t, name) + if err := SaveServerID(name, "srv-old-aws"); err != nil { + t.Fatal(err) + } + if err := SaveEnrollMethod(name, EnrollMethodToken); err != nil { + t.Fatal(err) + } + if _, aws := ResolveAwsRefreshServerID(name); aws { + t.Fatal("a token re-enrollment must not keep refreshing via the old aws server id") + } + }) + + t.Run("env server id differs from the recorded one", func(t *testing.T) { + name := "probe-env-differs" + defer cleanup(t, name) + if err := SaveEnrollMethod(name, EnrollMethodAws); err != nil { + t.Fatal(err) + } + if err := SaveServerID(name, "srv-recorded"); err != nil { + t.Fatal(err) + } + t.Setenv(INFISICAL_KMIP_SERVER_ID_KEY, "srv-from-environment") + id, aws := ResolveAwsRefreshServerID(name) + if !aws || id != "srv-recorded" { + t.Fatalf("must re-authenticate as the recorded server, got id=%q aws=%v", id, aws) + } + }) + + t.Run("unrecognised method", func(t *testing.T) { + name := "probe-unknown" + defer cleanup(t, name) + if err := SaveEnrollMethod(name, "kubernetes"); err != nil { + t.Fatal(err) + } + if err := SaveServerID(name, "srv-x"); err != nil { + t.Fatal(err) + } + if _, aws := ResolveAwsRefreshServerID(name); aws { + t.Fatal("only aws enrollment refreshes via STS") + } + }) + + t.Run("method is overwritten on re-enrollment", func(t *testing.T) { + name := "probe-overwrite" + defer cleanup(t, name) + if err := SaveEnrollMethod(name, EnrollMethodToken); err != nil { + t.Fatal(err) + } + if err := SaveEnrollMethod(name, EnrollMethodAws); err != nil { + t.Fatal(err) + } + if err := SaveServerID(name, "srv-now-aws"); err != nil { + t.Fatal(err) + } + if got, _ := LoadStoredEnrollMethod(name); got != EnrollMethodAws { + t.Fatalf("re-enrollment should replace the method, got %q", got) + } + if id, aws := ResolveAwsRefreshServerID(name); !aws || id != "srv-now-aws" { + t.Fatalf("expected refresh as srv-now-aws, got id=%q aws=%v", id, aws) + } + }) +}