cliconfig+main: Allow oci_mirror as a new provider installation method

It's now valid to include an oci_mirror block in the provider_installation
block in the CLI configuration, specifying that OpenTofu should try to
install providers from OCI repositories based on a template that maps
from OpenTofu-style provider source addresses into OCI repository
addresses.

The getproviders.Source implementation for this was added in a previous
commit, so this is mainly just wiring it up to the cliconfig layer and
the dependency wiring code in package main.

Signed-off-by: Martin Atkins <mart@degeneration.co.uk>
This commit is contained in:
Martin Atkins
2025-03-19 16:36:44 -07:00
parent 367c7c899a
commit 6dab83e3bc
14 changed files with 694 additions and 9 deletions

View File

@@ -15,11 +15,14 @@ import (
"github.com/apparentlymart/go-userdirs/userdirs"
"github.com/hashicorp/terraform-svchost/disco"
orasRemote "oras.land/oras-go/v2/registry/remote"
orasAuth "oras.land/oras-go/v2/registry/remote/auth"
"github.com/opentofu/opentofu/internal/addrs"
"github.com/opentofu/opentofu/internal/command/cliconfig"
"github.com/opentofu/opentofu/internal/command/cliconfig/ociauthconfig"
"github.com/opentofu/opentofu/internal/getproviders"
"github.com/opentofu/opentofu/internal/httpclient"
"github.com/opentofu/opentofu/internal/tfdiags"
)
@@ -203,7 +206,7 @@ func implicitProviderSource(services *disco.Disco) getproviders.Source {
return getproviders.MultiSource(searchRules)
}
func providerSourceForCLIConfigLocation(loc cliconfig.ProviderInstallationLocation, services *disco.Disco, _ ociCredsPolicyBuilder) (getproviders.Source, tfdiags.Diagnostics) {
func providerSourceForCLIConfigLocation(loc cliconfig.ProviderInstallationLocation, services *disco.Disco, makeOCICredsPolicy ociCredsPolicyBuilder) (getproviders.Source, tfdiags.Diagnostics) {
if loc == cliconfig.ProviderInstallationDirect {
return getproviders.NewMemoizeSource(
getproviders.NewRegistrySource(services),
@@ -237,11 +240,24 @@ func providerSourceForCLIConfigLocation(loc cliconfig.ProviderInstallationLocati
}
return getproviders.NewHTTPMirrorSource(url, services.CredentialsSource()), nil
// TODO: Once we implement an OCI-Distribution-based mirror source in a
// future commit, we'll use the ociCredsPolicyBuilder callback as part of
// initializing it so that it can find any credentials it needs to do its work.
// For now this is just a stub to illustrate where future work should
// continue, to help split this OCI integration work across multiple changes.
case cliconfig.ProviderInstallationOCIMirror:
mappingFunc := loc.RepositoryMapping
return getproviders.NewOCIRegistryMirrorSource(
mappingFunc,
func(ctx context.Context, registryDomain, repositoryName string) (getproviders.OCIRepositoryStore, error) {
// We intentionally delay the finalization of the credentials policy until
// just before we need it because most OpenTofu commands don't install
// providers at all, and even those that do only need to do this if
// actually interacting with an OCI mirror, so we can avoid doing
// this work at all most of the time.
credsPolicy, err := makeOCICredsPolicy(ctx)
if err != nil {
// This deals with only a small number of errors that we can't catch during CLI config validation
return nil, fmt.Errorf("invalid credentials configuration for OCI registries: %w", err)
}
return getOCIRepositoryStore(ctx, registryDomain, repositoryName, credsPolicy)
},
), nil
default:
// We should not get here because the set of cases above should
@@ -261,3 +277,77 @@ func providerDevOverrides(configs []*cliconfig.ProviderInstallation) map[addrs.P
// ignore any additional configurations in here.
return configs[0].DevOverrides
}
// getOCIRepositoryStore instantiates a [getproviders.OCIRepositoryStore] implementation to use
// when accessing the given repository on the given registry, using the given OCI credentials
// policy to decide which credentials to use.
func getOCIRepositoryStore(ctx context.Context, registryDomain, repositoryName string, credsPolicy ociauthconfig.CredentialsConfigs) (getproviders.OCIRepositoryStore, error) {
// We currently use the ORAS-Go library to satisfy the [getproviders.OCIRepositoryStore]
// interface, which is easy because that interface was designed to match a subset of
// the ORAS-Go API since we had no particular need to diverge from it. However, we consider
// ORAS-Go to be an implementation detail here and so we should avoid any ORAS-Go
// types becoming part of the direct public API between packages.
// ORAS-Go has a bit of an impedence mismatch with us in that it thinks of credentials
// as being a per-registry thing rather than a per-repository thing, so we deal with
// the credSource resolution ourselves here and then just return whatever we found to
// ORAS when it asks through its callback. In practice we only interact with one
// repository per client so this is just a little inconvenient and not a practical problem.
credSource, err := credsPolicy.CredentialsSourceForRepository(ctx, registryDomain, repositoryName)
if ociauthconfig.IsCredentialsNotFoundError(err) {
credSource = nil // we'll just try without any credentials, then
} else if err != nil {
return nil, fmt.Errorf("finding credentials for %q: %w", registryDomain, err)
}
client := &orasAuth.Client{
Client: httpclient.New(), // the underlying HTTP client to use, preconfigured with OpenTofu's User-Agent string
Credential: func(ctx context.Context, hostport string) (orasAuth.Credential, error) {
if hostport != registryDomain {
// We should not send the credentials we selected to any registry
// other than the one they were configured for.
return orasAuth.EmptyCredential, nil
}
if credSource == nil {
return orasAuth.EmptyCredential, nil
}
creds, err := credSource.Credentials(ctx, ociCredentialsLookupEnv{})
if ociauthconfig.IsCredentialsNotFoundError(err) {
return orasAuth.EmptyCredential, nil
}
if err != nil {
return orasAuth.Credential{}, err
}
return creds.ToORASCredential(), nil
},
Cache: orasAuth.NewCache(),
}
reg, err := orasRemote.NewRegistry(registryDomain)
if err != nil {
return nil, err // This is only for registryDomain validation errors, and we should've caught those much earlier than here
}
reg.Client = client
err = reg.Ping(ctx) // tests whether the given domain refers to a valid OCI repository and will accept the credentials
if err != nil {
return nil, fmt.Errorf("failed to contact OCI registry at %q: %w", registryDomain, err)
}
repo, err := reg.Repository(ctx, repositoryName)
if err != nil {
return nil, err // This is only for repositoryName validation errors, and we should've caught those much earlier than here
}
// NOTE: At this point we don't yet know if the named repository actually exists
// in the registry. The caller will find that out when they try to interact
// with the methods of [getproviders.OCIRepositoryStore].
return repo, nil
}
// ociCredentialsLookupEnv is our implementation of ociauthconfig.CredentialsLookupEnvironment
// used when resolving the selected credentials for a particular OCI repository.
type ociCredentialsLookupEnv struct{}
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")
}