Files
opentf/internal/getproviders/didyoumean.go
Martin Atkins d2bef1fd47 Adopt OpenTofu's own "svchost" module
Previously we were using a third-party library, but that doesn't have any
support for passing context.Context through its API and so isn't suitable
for our goals of adding OpenTelemetry tracing for all outgoing network
requests.

We now have our own fork that is updated to use context.Context. It also
has a slightly reduced scope no longer including various details that
are tightly-coupled to our cliconfig mechanism and so better placed in the
main OpenTofu codebase so we can evolve it in future without making
lockstep library releases.

The "registry-address" library also uses svchost and uses some of its types
in its public API, so this also incorporates v2 of that library that is
updated to use our own svchost module.

Unfortunately this commit is a mix of mechanical updates to the new
libraries and some new code dealing with the functionality that is removed
in our fork of svchost. The new code is primarily in the "svcauthconfig"
package, which is similar in purpose "ociauthconfig" but for OpenTofu's
own auth mechanism instead of the OCI Distribution protocol's auth
mechanism.

This includes some additional plumbing of context.Context where it was
possible to do so without broad changes to files that would not otherwise
have been included in this commit, but there are a few leftover spots that
are context.TODO() which we'll address separately in later commits.

This removes the temporary workaround from d079da6e9e, since we are now
able to plumb the OpenTelemetry span tree all the way to the service
discovery requests.

Signed-off-by: Martin Atkins <mart@degeneration.co.uk>
2025-06-12 09:37:59 -07:00

269 lines
9.6 KiB
Go

// Copyright (c) The OpenTofu Authors
// SPDX-License-Identifier: MPL-2.0
// Copyright (c) 2023 HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package getproviders
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"path"
"github.com/hashicorp/go-retryablehttp"
"github.com/opentofu/svchost"
"github.com/opentofu/opentofu/internal/addrs"
)
// MissingProviderSuggestion takes a provider address that failed installation
// due to the remote registry reporting that it didn't exist, and attempts
// to find another provider that the user might have meant to select.
//
// If the result is equal to the given address then that indicates that there
// is no suggested alternative to offer, either because the function
// successfully determined there is no recorded alternative or because the
// lookup failed somehow. We don't consider a failure to find a suggestion
// as an installation failure, because the caller should already be reporting
// that the provider didn't exist anyway and this is only extra context for
// that error message.
//
// The result of this is a best effort, so any UI presenting it should be
// careful to give it only as a possibility and not necessarily a suitable
// replacement for the given provider.
//
// In practice today this function only knows how to suggest alternatives for
// "default" providers, which is to say ones that are in the hashicorp
// namespace in the OpenTofu registry. It will always return no result for
// any other provider. That might change in future if we introduce other ways
// to discover provider suggestions.
//
// If the given context is cancelled then this function might not return a
// renaming suggestion even if one would've been available for a completed
// request.
func MissingProviderSuggestion(ctx context.Context, addr addrs.Provider, source Source, reqs Requirements) addrs.Provider {
if !addrs.IsDefaultProvider(addr) {
return addr
}
// Before possibly looking up legacy naming, see if the user has another provider
// named in their requirements that is of the same type, and offer that
// as a suggestion
for req := range reqs {
if req != addr && req.Type == addr.Type {
return req
}
}
// Our strategy here, for a default provider, is to use the default
// registry's special API for looking up "legacy" providers and try looking
// for a legacy provider whose type name matches the type of the given
// provider. This should then find a suitable answer for any provider
// that was originally auto-installable in v0.12 and earlier but moved
// into a non-default namespace as part of introducing the hierarchical
// provider namespace.
//
// To achieve that, we need to find the direct registry client in
// particular from the given source, because that is the only Source
// implementation that can actually handle a legacy provider lookup.
regSource := findLegacyProviderLookupSource(addr.Hostname, source)
if regSource == nil {
// If there's no direct registry source in the installation config
// then we can't provide a renaming suggestion.
return addr
}
defaultNS, redirectNS, err := regSource.lookupLegacyProviderNamespace(ctx, addr.Hostname, addr.Type)
if err != nil {
return addr
}
switch {
case redirectNS != "":
return addrs.Provider{
Hostname: addr.Hostname,
Namespace: redirectNS,
Type: addr.Type,
}
default:
return addrs.Provider{
Hostname: addr.Hostname,
Namespace: defaultNS,
Type: addr.Type,
}
}
}
// findLegacyProviderLookupSource tries to find a *RegistrySource that can talk
// to the given registry host in the given Source. It might be given directly,
// or it might be given indirectly via a MultiSource where the selector
// includes a wildcard for registry.opentofu.org.
//
// Returns nil if the given source does not have any configured way to talk
// directly to the given host.
//
// If the given source contains multiple sources that can talk to the given
// host directly, the first one in the sequence takes preference. In practice
// it's pointless to have two direct installation sources that match the same
// hostname anyway, so this shouldn't arise in normal use.
func findLegacyProviderLookupSource(host svchost.Hostname, source Source) *RegistrySource {
switch source := source.(type) {
case *RegistrySource:
// Easy case: the source is a registry source directly, and so we'll
// just use it.
return source
case *MemoizeSource:
// Also easy: the source is a memoize wrapper, so defer to its
// underlying source.
return findLegacyProviderLookupSource(host, source.underlying)
case MultiSource:
// Trickier case: if it's a multisource then we need to scan over
// its selectors until we find one that is a *RegistrySource _and_
// that is configured to accept arbitrary providers from the
// given hostname.
// For our matching purposes we'll use an address that would not be
// valid as a real provider FQN and thus can only match a selector
// that has no filters at all or a selector that wildcards everything
// except the hostname, like "registry.opentofu.org/*/*"
matchAddr := addrs.Provider{
Hostname: host,
// Other fields are intentionally left empty, to make this invalid
// as a specific provider address.
}
for _, selector := range source {
// If this source has suitable matching patterns to install from
// the given hostname then we'll recursively search inside it
// for *RegistrySource objects.
if selector.CanHandleProvider(matchAddr) {
ret := findLegacyProviderLookupSource(host, selector.Source)
if ret != nil {
return ret
}
}
}
// If we get here then there were no selectors that are both configured
// to handle modules from the given hostname and that are registry
// sources, so we fail.
return nil
default:
// This source cannot be and cannot contain a *RegistrySource, so
// we fail.
return nil
}
}
// lookupLegacyProviderNamespace is a special method available only on
// RegistrySource which can deal with legacy provider addresses that contain
// only a type and leave the namespace implied.
//
// It asks the registry at the given hostname to provide a default namespace
// for the given provider type, which can be combined with the given hostname
// and type name to produce a fully-qualified provider address.
//
// Not all unqualified type names can be resolved to a default namespace. If
// the request fails, this method returns an error describing the failure.
//
// This method exists only to allow compatibility with unqualified names
// in older configurations. New configurations should be written so as not to
// depend on it, and this fallback mechanism will likely be removed altogether
// in a future OpenTofu version.
func (s *RegistrySource) lookupLegacyProviderNamespace(ctx context.Context, hostname svchost.Hostname, typeName string) (string, string, error) {
client, err := s.registryClient(ctx, hostname)
if err != nil {
return "", "", err
}
return client.legacyProviderDefaultNamespace(ctx, typeName)
}
// legacyProviderDefaultNamespace returns the raw address strings produced by
// the registry when asked about the given unqualified provider type name.
// The returned namespace string is taken verbatim from the registry's response.
//
// This method exists only to allow compatibility with unqualified names
// in older configurations. New configurations should be written so as not to
// depend on it.
func (c *registryClient) legacyProviderDefaultNamespace(ctx context.Context, typeName string) (string, string, error) {
endpointPath, err := url.Parse(path.Join("-", typeName, "versions"))
if err != nil {
// Should never happen because we're constructing this from
// already-validated components.
return "", "", err
}
endpointURL := c.baseURL.ResolveReference(endpointPath)
req, err := retryablehttp.NewRequest("GET", endpointURL.String(), nil)
if err != nil {
return "", "", err
}
req = req.WithContext(ctx)
c.addHeadersToRequest(req.Request)
// This is just to give us something to return in error messages. It's
// not a proper provider address.
placeholderProviderAddr := addrs.NewLegacyProvider(typeName)
resp, err := c.httpClient.Do(req)
if err != nil {
return "", "", c.errQueryFailed(placeholderProviderAddr, err)
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
// Great!
case http.StatusNotFound:
return "", "", ErrProviderNotFound{
Provider: placeholderProviderAddr,
}
case http.StatusUnauthorized, http.StatusForbidden:
return "", "", c.errUnauthorized(placeholderProviderAddr.Hostname)
default:
return "", "", c.errQueryFailed(placeholderProviderAddr, errors.New(resp.Status))
}
type ResponseBody struct {
Id string `json:"id"`
MovedTo string `json:"moved_to"`
}
var body ResponseBody
dec := json.NewDecoder(resp.Body)
if err := dec.Decode(&body); err != nil {
return "", "", c.errQueryFailed(placeholderProviderAddr, err)
}
provider, diags := addrs.ParseProviderSourceString(body.Id)
if diags.HasErrors() {
return "", "", fmt.Errorf("Error parsing provider ID from Registry: %s", diags.Err())
}
if provider.Type != typeName {
return "", "", fmt.Errorf("Registry returned provider with type %q, expected %q", provider.Type, typeName)
}
var movedTo addrs.Provider
if body.MovedTo != "" {
movedTo, diags = addrs.ParseProviderSourceString(body.MovedTo)
if diags.HasErrors() {
return "", "", fmt.Errorf("Error parsing provider ID from Registry: %s", diags.Err())
}
if movedTo.Type != typeName {
return "", "", fmt.Errorf("Registry returned provider with type %q, expected %q", movedTo.Type, typeName)
}
}
return provider.Namespace, movedTo.Namespace, nil
}