diff --git a/cmd/tofu/oci_distribution.go b/cmd/tofu/oci_distribution.go index 2e0f953470..98858ab734 100644 --- a/cmd/tofu/oci_distribution.go +++ b/cmd/tofu/oci_distribution.go @@ -12,9 +12,12 @@ package main import ( "context" "fmt" + "log" orasRemote "oras.land/oras-go/v2/registry/remote" orasAuth "oras.land/oras-go/v2/registry/remote/auth" + orasCreds "oras.land/oras-go/v2/registry/remote/credentials" + orasCredsTrace "oras.land/oras-go/v2/registry/remote/credentials/trace" "github.com/opentofu/opentofu/internal/command/cliconfig/ociauthconfig" "github.com/opentofu/opentofu/internal/getmodules" @@ -114,6 +117,44 @@ var _ ociauthconfig.CredentialsLookupEnvironment = ociCredentialsLookupEnv{} // QueryDockerCredentialHelper implements ociauthconfig.CredentialsLookupEnvironment. func (o ociCredentialsLookupEnv) QueryDockerCredentialHelper(ctx context.Context, helperName string, serverURL string) (ociauthconfig.DockerCredentialHelperGetResult, error) { - // TODO: Implement this - return ociauthconfig.DockerCredentialHelperGetResult{}, fmt.Errorf("support for Docker-style credential helpers is not yet available") + // (just because this type name is very long to keep repeating in full) + type Result = ociauthconfig.DockerCredentialHelperGetResult + + // We currently use the ORAS-Go implementation of the Docker + // credential helper protocol, because we already depend on + // that library for our OCI registry interactions elsewhere. + // ORAS refers to this protocol as "native store", rather + // than "Docker-style Credential Helper", but it's the + // same protocol nonetheless. + + ctx = orasCredsTrace.WithExecutableTrace(ctx, &orasCredsTrace.ExecutableTrace{ + ExecuteStart: func(executableName, action string) { + log.Printf("[DEBUG] Executing docker-style credentials helper %q for %s", helperName, serverURL) + }, + ExecuteDone: func(executableName, action string, err error) { + if err != nil { + log.Printf("[ERROR] Docker-style credential helper %q failed for %s: %s", helperName, serverURL, err) + } + }, + }) + + store := orasCreds.NewNativeStore(helperName) + creds, err := store.Get(ctx, serverURL) + if err != nil { + return Result{}, fmt.Errorf("%q credential helper failed: %w", helperName, err) + } + if creds.AccessToken != "" || creds.RefreshToken != "" { + // A little awkward: orasAuth.Credential is a more general type than + // what the Docker credential helper needs: it has fields for OAuth-style + // credentials even though the credential helper protocol only supports + // username/password style. So for completeness/robustness we check + // the OAuth fields and fail if they are set, but it should not actually + // be possible for them to be set in practice. + return Result{}, fmt.Errorf("%q credential helper returned OAuth-style credentials, but only username/password-style is allowed from a credential helper", helperName) + } + return Result{ + ServerURL: serverURL, + Username: creds.Username, + Secret: creds.Password, + }, nil } diff --git a/cmd/tofu/oci_distribution_test.go b/cmd/tofu/oci_distribution_test.go new file mode 100644 index 0000000000..d6878cd15f --- /dev/null +++ b/cmd/tofu/oci_distribution_test.go @@ -0,0 +1,53 @@ +// Copyright (c) The OpenTofu Authors +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2023 HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package main + +import ( + "os" + "strings" + "testing" +) + +func TestOCICredentialsLookupEnv_DockerCredHelper(t *testing.T) { + // This ociCredentialsLookupEnv is the concrete implementation of + // the abstraction that is created in package ociauthconfig, and + // so it depends directly on a real (non-substitutable) + // implementation of Docker credential helpers. + // + // Properly testing this would require a functioning credential + // helper executable, which is difficult to arrange for in a + // portable manner to allow this test to run across multiple + // platforms. It's just a thin wrapper around the ORAS-Go + // implementation anyway, and that has its own tests upstream + // so this test just settles for the compromise of making a + // call that we expect to fail and checking that it fails in + // the way we expect, to give confidence that we really did + // ask the ORAS-Go library to attempt to fetch credentials. + + // To prevent this from accidentally executing some real + // credential helper that might be installed on the system + // where the tests are running, we'll temporarily override + // the PATH environment variable to include only an empty + // directory. + emptyDir := os.TempDir() + os.Setenv("PATH", emptyDir) + + env := ociCredentialsLookupEnv{} + _, err := env.QueryDockerCredentialHelper(t.Context(), "fake-for-testing", "https://example.com") + + if err == nil { + t.Fatal("unexpected success; want error") + } + + // The exact details of the error message can vary between + // platforms, but it should always mention that it was + // trying to execute the specified credential helper + // executable. + wantErr := `docker-credential-fake-for-testing` + if gotErr := err.Error(); !strings.Contains(gotErr, wantErr) { + t.Errorf("wrong error\ngot: %s\nwant substring: %s", gotErr, wantErr) + } +}