mirror of
https://github.com/opentffoundation/opentf.git
synced 2025-12-19 17:59:05 -05:00
OpenTelemetry has various Go packages split across several Go modules that often need to be carefully upgraded together. And in particular, we are using the "semconv" package in conjunction with the OpenTelemetry SDK's "resource" package in a way that requires that they both agree on which version of the OpenTelemetry Semantic Conventions are being followed. To help avoid "dependency hell" situations when upgrading, this centralizes all of our direct calls into the OpenTelemetry SDK and tracing API into packages under internal/tracing, by exposing a few thin wrapper functions that other packages can use to access the same functionality indirectly. We only use a relatively small subset of the OpenTelemetry library surface area, so we don't need too many of these reexports and they should not represent a significant additional maintenance burden. For the semconv and resource interaction in particular this also factors that out into a separate helper function with a unit test, so we should notice quickly whenever they become misaligned. This complements the end-to-end test previously added in opentofu/opentofu#3447 to give us faster feedback about this particular problem, while the end-to-end test has the broader scope of making sure there aren't any errors at all when initializing OpenTelemetry tracing. Finally, this also replaces the constants we previously had in package traceaddrs with functions that return attribute.KeyValue values directly. This matches the API style used by the OpenTelemetry semconv packages, and makes the calls to these helpers from elsewhere in the system a little more concise. Signed-off-by: Martin Atkins <mart@degeneration.co.uk>
778 lines
35 KiB
Go
778 lines
35 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"
|
|
"io"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/apparentlymart/go-versions/versions"
|
|
ociv1 "github.com/opencontainers/image-spec/specs-go/v1"
|
|
orasErrors "oras.land/oras-go/v2/errdef"
|
|
orasRegistryErrors "oras.land/oras-go/v2/registry/remote/errcode"
|
|
|
|
"github.com/opentofu/opentofu/internal/addrs"
|
|
"github.com/opentofu/opentofu/internal/tracing"
|
|
"github.com/opentofu/opentofu/internal/tracing/traceattrs"
|
|
)
|
|
|
|
// ociIndexManifestArtifactType is the artifact type we expect for the top-level
|
|
// index manifest for an OpenTofu provider version.
|
|
//
|
|
// If a selected tag refers to a manifest that either lacks an artifact type
|
|
// or has a different artifact type then OpenTofu will reject it with an
|
|
// error indicating that it seems to be something other than an OpenTofu provider.
|
|
const ociIndexManifestArtifactType = "application/vnd.opentofu.provider"
|
|
|
|
// ociPackageManifestArtifactType is the artifact type we expect for each of the
|
|
// individual manifests listed in a provider version's top-level index manifest.
|
|
//
|
|
// OpenTofu will silently ignore any listed descriptors that either lack an artifact
|
|
// type or use a different one, both so that future versions of OpenTofu can
|
|
// be sensitive to additional artifact types if needed and so that those creating
|
|
// an artifact can choose to blend in other non-OpenTofu-related artifacts if
|
|
// they have some reason to do that.
|
|
//
|
|
// All descriptors with this artifact type MUST include a "platform" object
|
|
// with "os" and "architecture" set to match the platform that the individual
|
|
// package is built for. OpenTofu and OCI both use Go's codenames for operating
|
|
// systems and CPU architectures, so these fields should exactly match the
|
|
// names that would be used with this package's [Platform] type.
|
|
//
|
|
// OpenTofu does not currently make any use of the other properties defined for
|
|
// a "platform" object, and so will silently ignore any descriptors that set
|
|
// those properties. Future versions of OpenTofu might be able to use finer-grain
|
|
// platform selection properties, in which case those versions should treat
|
|
// a descriptor that uses those additional properties as higher precedence than
|
|
// one that doesn't so that manifest authors can include both a specific descriptor
|
|
// and a fallback descriptor with only os/architecture intended for use by
|
|
// earlier OpenTofu versions.
|
|
const ociPackageManifestArtifactType = "application/vnd.opentofu.provider-target"
|
|
|
|
// ociImageManifestSizeLimit is the maximum size of artifact manifest (aka "image
|
|
// manifest") we'll accept. This 4MiB value matches the recommended limit for
|
|
// repositories to accept on push from the OCI Distribution v1.1 spec:
|
|
//
|
|
// https://github.com/opencontainers/distribution-spec/blob/v1.1.0/spec.md#pushing-manifests
|
|
const ociImageManifestSizeLimitMiB = 4
|
|
|
|
// OCIRegistryMirrorSource is a source that treats one or more repositories
|
|
// in a registry implementing the OCI Distribution protocol as a kind of
|
|
// provider mirror.
|
|
//
|
|
// This is conceptually similar to [HTTPMirrorSource], but whereas that one
|
|
// acts as a client for the OpenTofu-specific "provider mirror protocol"
|
|
// this one instead relies on a user-configured template to map provider
|
|
// source addresses onto OCI repository addresses and then uses tags in
|
|
// the selected registry to discover the available versions, and OCI
|
|
// manifests to represent their metadata.
|
|
//
|
|
// This implementation is currently intended to be compatible with
|
|
// OCI Distribution v1.1.0:
|
|
//
|
|
// https://github.com/opencontainers/distribution-spec/blob/v1.1.0/spec.md
|
|
type OCIRegistryMirrorSource struct {
|
|
// resolveOCIRepositoryAddr represents this source's rule for mapping
|
|
// OpenTofu-style provider source addresses into OCI Distribution
|
|
// repository addresses, which include both the domain name (and
|
|
// optional port number) of the registry where the repository is
|
|
// hosted and the name of the repository within that registry.
|
|
//
|
|
// This MUST behave as a pure function: a specific given provider
|
|
// address must always return the same results, because it will
|
|
// be called multiple times across the steps of selecting a provider
|
|
// version.
|
|
//
|
|
// Implementers are responsible for somehow dealing with the fact
|
|
// that OpenTofu-style provider source addresses support a
|
|
// considerably wider set of Unicode characters than OCI Distribution
|
|
// repository names do. That could mean either translating unsupported
|
|
// characters into a different representation, or as a last resort
|
|
// returning an error message explaining the problem in terms that make
|
|
// sense for however the end user would've defined the mapping rule.
|
|
//
|
|
// If this function returns with a nil error then registryDomain
|
|
// must be a valid domain name optionally followed by a colon
|
|
// and a decimal port number, and repositoryName must be a string
|
|
// conforming to the "<name>" pattern defined in the OCI Distribution
|
|
// specification. The source will return low-quality error messages
|
|
// if the results are unsuitable, and so implementers should prefer
|
|
// to return their own higher-quality error diagnostics if no valid
|
|
// mapping is possible.
|
|
//
|
|
// Any situation where the requested provider cannot be supported
|
|
// _at all_ MUST return an instance of [ErrProviderNotFound] so
|
|
// that a [MultiSource] can successfully blend the results from
|
|
// this and other sources.
|
|
resolveOCIRepositoryAddr func(addr addrs.Provider) (registryDomain, repositoryName string, err error)
|
|
|
|
// getOCIRepositoryStore is the dependency inversion adapter for
|
|
// obtaining a suitably-configured client for the given repository
|
|
// name on the given OCI Distribution registry domain.
|
|
//
|
|
// If successful, the returned client should be preconfigured with
|
|
// any credentials that are needed to access content from the
|
|
// given repository. Implementers can assume that the client will
|
|
// be used for only a short period after the call to this function,
|
|
// so e.g. it's valid to use time-limited credentials with a validity
|
|
// period on the order of 5 to 10 minutes if appropriate.
|
|
//
|
|
// Errors from this function should represent "operational-related"
|
|
// situations like a failure to execute a credentials helper or
|
|
// failure to issue a temporary access token, and will be presented
|
|
// in the UI as a diagnostic with terminology like "Failed to access
|
|
// OCI registry".
|
|
getOCIRepositoryStore func(ctx context.Context, registryDomain, repositoryName string) (OCIRepositoryStore, error)
|
|
|
|
// We keep an internal cache of the most-recently-instantiated
|
|
// repository store object because in the common case there will
|
|
// be call to AvailableVersions immediately followed by
|
|
// PackageMeta with the same provider address and this avoids
|
|
// us needing to repeat the translation and instantiation again.
|
|
storeCacheMutex sync.Mutex
|
|
storeCacheProvider addrs.Provider
|
|
storeCacheStore OCIRepositoryStore
|
|
storeCacheRegistryDomain string
|
|
storeCacheRepositoryName string
|
|
}
|
|
|
|
var _ Source = (*OCIRegistryMirrorSource)(nil)
|
|
|
|
func NewOCIRegistryMirrorSource(
|
|
_ context.Context,
|
|
resolveRepositoryAddr func(addr addrs.Provider) (registryDomain, repositoryName string, err error),
|
|
getRepositoryStore func(ctx context.Context, registryDomain, repositoryName string) (OCIRepositoryStore, error),
|
|
) *OCIRegistryMirrorSource {
|
|
return &OCIRegistryMirrorSource{
|
|
resolveOCIRepositoryAddr: resolveRepositoryAddr,
|
|
getOCIRepositoryStore: getRepositoryStore,
|
|
}
|
|
}
|
|
|
|
// AvailableVersions implements Source.
|
|
func (o *OCIRegistryMirrorSource) AvailableVersions(ctx context.Context, provider addrs.Provider) (VersionList, Warnings, error) {
|
|
ctx, span := tracing.Tracer().Start(
|
|
ctx, "Get available versions from oci_mirror",
|
|
tracing.SpanAttributes(
|
|
traceattrs.OpenTofuProviderAddress(provider.String()),
|
|
),
|
|
)
|
|
defer span.End()
|
|
|
|
store, _, _, err := o.getRepositoryStore(ctx, provider)
|
|
if err != nil {
|
|
tracing.SetSpanError(span, err)
|
|
return nil, nil, err
|
|
}
|
|
|
|
var ret VersionList
|
|
err = store.Tags(ctx, "", func(tagNames []string) error {
|
|
for _, tagName := range tagNames {
|
|
// We're only interested in the subset of tag names that we can parse
|
|
// as version numbers. However, the OCI tag name syntax does not allow
|
|
// the "+" symbol that can appear in the version number syntax, so we
|
|
// expect the underscore to stand in for that.
|
|
maybeVersionStr := strings.ReplaceAll(tagName, "_", "+")
|
|
version, err := versions.ParseVersion(maybeVersionStr)
|
|
if err != nil {
|
|
// Not a version tag, so ignored.
|
|
continue
|
|
}
|
|
ret = append(ret, version)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
if errRepresentsOCIProviderNotFound(err) {
|
|
// We treat "not found" as special because this function might be
|
|
// called from [MultiSource.AvailableVersions] and that relies
|
|
// on us returning this specific error type to represent that this
|
|
// source can't handle the requested provider at all, so that it
|
|
// can blend the results of multiple sources and only return an
|
|
// error if _none_ of the eligible sources can handle the
|
|
// requested provider. We assume that if the given provider address
|
|
// translated to an OCI repository that doesn't exist then that
|
|
// means this source does not know anything about that provider,
|
|
// but other [MultiSource] candidates should still be offered it.
|
|
err := ErrProviderNotFound{
|
|
Provider: provider,
|
|
}
|
|
tracing.SetSpanError(span, err)
|
|
return nil, nil, err
|
|
}
|
|
err := fmt.Errorf("listing tags from OCI repository: %w", err)
|
|
tracing.SetSpanError(span, err)
|
|
return nil, nil, err
|
|
}
|
|
ret.Sort()
|
|
return ret, nil, nil
|
|
}
|
|
|
|
// errRepresentsOCIProviderNotFound returns true if the given error seems like
|
|
// one of the ways that typical implementations of ORAS-Go's [registry.Repository]
|
|
// report that the specified repository doesn't exist, since we need to
|
|
// understand that as meaning "provider not found" when we translate the
|
|
// error for the result in [OCIRegistryMirrorSource.AvailableVersions].
|
|
//
|
|
// The result of this function is meaningful only for errors returned from
|
|
// tag-listing requests.
|
|
func errRepresentsOCIProviderNotFound(err error) bool {
|
|
if err == nil {
|
|
return false // the absense of an error cannot possibly signal that a provider wasn't found
|
|
} else if errors.Is(err, orasErrors.ErrNotFound) {
|
|
return true // This error value is used by the local-only implementations of the interface
|
|
} else if err, ok := err.(*orasRegistryErrors.ErrorResponse); ok {
|
|
// This error type deals with errors returned from remote
|
|
// registries when using the OCI Distribution protocol.
|
|
// This case is a little trickier because different server
|
|
// implementations can potentially vary in how they report
|
|
// this problem, but the OCI Distribution specification
|
|
// says that:
|
|
// - 404 Not Found is the only valid error code to return
|
|
// from the tag-listing endpoint.
|
|
// - Remote servers MAY (but are not required to) return
|
|
// additional detail in a JSON-formatted response body
|
|
// that ORAS-Go then decodes into the "Errors" field
|
|
// of the error object.
|
|
// Therefore our strategy is to make a best effort to use
|
|
// the OCI-specific detail payload if it's available, but
|
|
// otherwise to assume that "404 Not Found" is a sufficient
|
|
// signal for "provider not found".
|
|
//
|
|
// Overall the goal here is to avoid masking other kinds of
|
|
// errors a server might return if possible, but to err
|
|
// on the side of "provider not found" if we're not sure
|
|
// because otherwise a failure of this source could block
|
|
// the provider installer from finding a suitable provider
|
|
// package from another source.
|
|
if len(err.Errors) == 0 { // less-precise handling for servers that don't return error details
|
|
return err.StatusCode == 404
|
|
}
|
|
for _, subErr := range err.Errors {
|
|
if subErr.Code == orasRegistryErrors.ErrorCodeNameUnknown || subErr.Code == orasRegistryErrors.ErrorCodeNameInvalid {
|
|
return true
|
|
}
|
|
// There are various other possible error codes that we're
|
|
// intentionally _not_ classifying as "provider not found"
|
|
// here, such as "unauthorized" or "denied" for invalid
|
|
// credentials, since those imply that the provider source
|
|
// configuration isn't correct and we want to give the
|
|
// operator clear feedback about that.
|
|
}
|
|
return false
|
|
}
|
|
return false
|
|
}
|
|
|
|
// PackageMeta implements Source.
|
|
func (o *OCIRegistryMirrorSource) PackageMeta(ctx context.Context, provider addrs.Provider, version Version, target Platform) (PackageMeta, error) {
|
|
ctx, span := tracing.Tracer().Start(
|
|
ctx, "Get metadata from oci_mirror",
|
|
tracing.SpanAttributes(
|
|
traceattrs.OpenTofuProviderAddress(provider.String()),
|
|
traceattrs.OpenTofuProviderVersion(version.String()),
|
|
traceattrs.OpenTofuTargetPlatform(target.String()),
|
|
),
|
|
)
|
|
defer span.End()
|
|
|
|
// Unfortunately we need to repeat our translation from provider address to
|
|
// OCI repository address here, but getRepositoryStore has a cache that
|
|
// allows us to reuse a previously-instantiated store if there are two
|
|
// consecutive calls for the same provider, as is commonly true when first
|
|
// calling AvailableVersions and then calling PackageMeta based on its result.
|
|
store, registryDomain, repositoryName, err := o.getRepositoryStore(ctx, provider)
|
|
if err != nil {
|
|
return PackageMeta{}, err
|
|
}
|
|
|
|
// The overall process here is:
|
|
// 1. Transform the version number into a tag name and resolve the descriptor
|
|
// associated with that tag name.
|
|
// 2. Fetch the blob associated with that descriptor, which should be an
|
|
// index manifest giving a descriptor for each platform supported by this
|
|
// version of the provider.
|
|
// 3. Choose the descriptor that matches the requested platform.
|
|
// 4. Fetch the blob associated with that second descriptor, which should be
|
|
// an image manifest that includes a layer descriptor for the blob containing
|
|
// the actual zip package we need to fetch.
|
|
// 5. Return a PackageMeta whose location is that zip blob, using [PackageOCIBlobArchive].
|
|
|
|
// The errors from the following helper functions must not be wrapped by this
|
|
// function because some of them are of specific types that get special
|
|
// treatment by callers.
|
|
indexDesc, err := fetchOCIDescriptorForVersion(ctx, version, store) // step 1
|
|
if err != nil {
|
|
return PackageMeta{}, err
|
|
}
|
|
index, err := fetchOCIIndexManifest(ctx, indexDesc, store) // step 2
|
|
if err != nil {
|
|
return PackageMeta{}, err
|
|
}
|
|
imageDesc, err := selectOCIImageManifest(index.Manifests, provider, version, target) // step 3
|
|
if err != nil {
|
|
return PackageMeta{}, err
|
|
}
|
|
imageManifest, err := fetchOCIImageManifest(ctx, imageDesc, store) // step 4
|
|
if err != nil {
|
|
return PackageMeta{}, err
|
|
}
|
|
blobDesc, err := selectOCILayerBlob(imageManifest.Layers) // a little more of step 4
|
|
if err != nil {
|
|
return PackageMeta{}, err
|
|
}
|
|
|
|
// The remainder of this is "step 5" from the overview above, adapting the information
|
|
// we fetched to suit OpenTofu's provider installer API.
|
|
|
|
// We'll announce the OpenTofu-style package hash that we're expecting as part of
|
|
// the metadata. This isn't strictly necessary since OCI blobs are content-addressed
|
|
// anyway and so we'll authenticate it using the same digest that identifies it
|
|
// during the subsequent fetch, but this makes this source consistent with
|
|
// [HTTPMirrorSource] and allows generating an explicit "checksum verified"
|
|
// authentication result after install.
|
|
expectedHash, err := hashFromOCIDigest(blobDesc.Digest)
|
|
if err != nil {
|
|
return PackageMeta{}, err
|
|
}
|
|
authentication := NewPackageHashAuthentication(target, []Hash{expectedHash})
|
|
|
|
// If we got through all of the above then we seem to have found a suitable
|
|
// package to install, but our job is only to describe its metadata.
|
|
return PackageMeta{
|
|
Provider: provider,
|
|
Version: version,
|
|
TargetPlatform: target,
|
|
Location: PackageOCIBlobArchive{
|
|
repoStore: store,
|
|
blobDescriptor: blobDesc,
|
|
registryDomain: registryDomain,
|
|
repositoryName: repositoryName,
|
|
},
|
|
Authentication: authentication,
|
|
|
|
// "Filename" isn't really a meaningful concept in an OCI registry, but
|
|
// that's okay because we don't care very much about it in OpenTofu
|
|
// either and so we can just populate something plausible here. This
|
|
// matches the way the package would be named in a traditional
|
|
// OpenTofu provider network mirror.
|
|
Filename: fmt.Sprintf("terraform-provider-%s_%s_%s_%s.zip", provider.Type, version, target.OS, target.Arch),
|
|
|
|
// TODO: Define an optional annotation that can announce which protocol
|
|
// versions are supported, so we can populate the ProtocolVersions
|
|
// field and can fail early if the provider clearly doesn't support
|
|
// any of the protocol versions that this OpenTofu version supports.
|
|
// Omitting this field is okay though, since some other mirror sources
|
|
// can't support it either: that just means that OpenTofu will discover
|
|
// the compatibility problem only after executing the plugin, rather
|
|
// than when installing it.
|
|
}, nil
|
|
}
|
|
|
|
// ForDisplay implements Source.
|
|
func (o *OCIRegistryMirrorSource) ForDisplay(provider addrs.Provider) string {
|
|
// We don't really have a good concise way to differentiate between
|
|
// instances of this source because the mapping from provider source
|
|
// address to OCI repository address is arbitrarily-defined by the
|
|
// user, so we'll just settle for this right now since this is result
|
|
// only typically used in error messages anyway. If this turns out to
|
|
// be too vague in practice than hopefully whatever complaint caused
|
|
// us to realize that will give a clue as to what additional information
|
|
// is worth including here and where we might derive that information
|
|
// from.
|
|
return "OCI registry provider mirror"
|
|
}
|
|
|
|
func (o *OCIRegistryMirrorSource) getRepositoryStore(ctx context.Context, provider addrs.Provider) (store OCIRepositoryStore, registryDomain string, repositoryName string, err error) {
|
|
o.storeCacheMutex.Lock()
|
|
defer o.storeCacheMutex.Unlock()
|
|
|
|
// If our cache is for the requested provider then we can reuse the store
|
|
// we previously instantiated for this provider.
|
|
if o.storeCacheProvider == provider {
|
|
return o.storeCacheStore, o.storeCacheRegistryDomain, o.storeCacheRepositoryName, nil
|
|
}
|
|
|
|
// Otherwise we'll instantiate a new one and overwrite our cache with it.
|
|
registryDomain, repositoryName, err = o.resolveOCIRepositoryAddr(provider)
|
|
if err != nil {
|
|
if notFoundErr, ok := err.(ErrProviderNotFound); ok {
|
|
// [MultiSource] relies on this particular error type being returned
|
|
// directly, without any wrapping.
|
|
return nil, "", "", notFoundErr
|
|
}
|
|
return nil, "", "", fmt.Errorf("selecting OCI repository address: %w", err)
|
|
}
|
|
store, err = o.getOCIRepositoryStore(ctx, registryDomain, repositoryName)
|
|
if err != nil {
|
|
if errors.Is(err, orasErrors.ErrNotFound) {
|
|
return nil, "", "", ErrProviderNotFound{
|
|
Provider: provider,
|
|
}
|
|
}
|
|
return nil, "", "", fmt.Errorf("accessing OCI registry at %s: %w", registryDomain, err)
|
|
}
|
|
o.storeCacheProvider = provider
|
|
o.storeCacheStore = store
|
|
o.storeCacheRegistryDomain = registryDomain
|
|
o.storeCacheRepositoryName = repositoryName
|
|
return store, registryDomain, repositoryName, nil
|
|
}
|
|
|
|
// OCIRepositoryStore is the interface used by [OCIRegistryMirrorSource] to
|
|
// interact with the content in a specific OCI repository.
|
|
type OCIRepositoryStore interface {
|
|
// Tags lists the tag names available in the repository.
|
|
//
|
|
// The OCI Distribution protocol uses pagination for the tag list and so
|
|
// the given function will be called for each page until either it returns
|
|
// an error or there are no pages left to retrieve.
|
|
//
|
|
// "last" is a token used to begin at some point other than the start of
|
|
// the list, but callers in this package always set it to an empty string
|
|
// to represent intent to retrieve the entire list and so implementers are
|
|
// allowed to return an error if "last" is non-empty.
|
|
Tags(ctx context.Context, last string, fn func(tags []string) error) error
|
|
|
|
// Resolve finds the descriptor associated with the given tag name in the
|
|
// repository, if any.
|
|
//
|
|
// tagName MUST conform to the pattern defined for "<reference> as a tag"
|
|
// from the OCI distribution specification, or the result is unspecified.
|
|
Resolve(ctx context.Context, tagName string) (ociv1.Descriptor, error)
|
|
|
|
// Fetch retrieves the content of a specific blob from the repository, identified
|
|
// by the digest in the given descriptor.
|
|
//
|
|
// Implementations of this function tend not to check that the returned content
|
|
// actually matches the digest and size in the descriptor, so callers MUST
|
|
// verify that somehow themselves before making use of the resulting content.
|
|
//
|
|
// Callers MUST close the returned reader after using it, since it's typically
|
|
// connected to an active network socket or file handle.
|
|
Fetch(ctx context.Context, target ociv1.Descriptor) (io.ReadCloser, error)
|
|
|
|
// The design of the above intentionally matches a subset of the interfaces
|
|
// defined in the ORAS-Go library, but we have our own specific interface here
|
|
// both to clearly define the minimal interface we depend on and so that our
|
|
// use of ORAS-Go can be treated as an implementation detail rather than as
|
|
// an API contract should we need to switch to a different approach in future.
|
|
//
|
|
// If you need to expand this while we're still relying on ORAS-Go, aim to
|
|
// match the corresponding interface in that library if at all possible so
|
|
// that we can minimize the amount of adapter code we need to write.
|
|
}
|
|
|
|
func fetchOCIDescriptorForVersion(ctx context.Context, version versions.Version, store OCIRepositoryStore) (ociv1.Descriptor, error) {
|
|
ctx, span := tracing.Tracer().Start(
|
|
ctx, "Resolve reference",
|
|
tracing.SpanAttributes(
|
|
traceattrs.OpenTofuProviderVersion(version.String()),
|
|
),
|
|
)
|
|
defer span.End()
|
|
prepErr := func(err error) error {
|
|
tracing.SetSpanError(span, err)
|
|
return err
|
|
}
|
|
|
|
// OCI tags don't support the "+" character used to mark the beginning of
|
|
// build metadata in semver-style version numbers, so we expect a tag name
|
|
// where those are replaced with "_".
|
|
tagName := strings.ReplaceAll(version.String(), "+", "_")
|
|
desc, err := store.Resolve(ctx, tagName)
|
|
if err != nil {
|
|
return ociv1.Descriptor{}, prepErr(fmt.Errorf("resolving tag %q: %w", tagName, err))
|
|
}
|
|
span.SetAttributes(
|
|
traceattrs.String("oci.manifest.digest", desc.Digest.String()),
|
|
traceattrs.String("opentofu.oci.reference.tag", tagName),
|
|
traceattrs.String("opentofu.oci.manifest.media_type", desc.MediaType),
|
|
)
|
|
// Not all store implementations can return the manifest's artifact type as part
|
|
// of the tag-resolution response, so we'll check this early if we can, but
|
|
// if not then we'll check it after we've fetched the manifest instead.
|
|
// (Doing this early if possible saves us one request and allows us to return
|
|
// a better error message, but we'll still catch a mismatched artifact type
|
|
// one way or another.)
|
|
if desc.ArtifactType != "" && desc.ArtifactType != ociIndexManifestArtifactType {
|
|
span.SetAttributes(
|
|
traceattrs.String("opentofu.oci.manifest.artifact_type", desc.ArtifactType),
|
|
)
|
|
switch desc.ArtifactType {
|
|
case "application/vnd.opentofu.provider-target":
|
|
// We'll get here for an incorrectly-constructed artifact layout where
|
|
// the tag refers directly to a specific platform's image manifest,
|
|
// rather than to the multi-platform index manifest.
|
|
return desc, prepErr(fmt.Errorf("tag refers directly to image manifest, but OpenTofu providers require an index manifest for multi-platform support"))
|
|
case "application/vnd.opentofu.modulepkg":
|
|
// If this happens to be using our artifact type for module packages then
|
|
// we'll return a specialized error, since confusion between providers
|
|
// and modules is common for those new to OpenTofu terminology.
|
|
return desc, prepErr(fmt.Errorf("selected OCI artifact is an OpenTofu module package, not a provider package"))
|
|
default:
|
|
// For any other artifact type we'll just mention it in the error message
|
|
// and hope the reader can figure out what that artifact type represents.
|
|
return desc, prepErr(fmt.Errorf("unsupported OCI artifact type %q", desc.ArtifactType))
|
|
}
|
|
}
|
|
if desc.MediaType != ociv1.MediaTypeImageIndex {
|
|
switch desc.MediaType {
|
|
case ociv1.MediaTypeImageManifest:
|
|
return desc, prepErr(fmt.Errorf("selected an OCI image manifest directly, but providers must be selected through a multi-platform index manifest"))
|
|
case ociv1.MediaTypeDescriptor:
|
|
return desc, prepErr(fmt.Errorf("found OCI descriptor but expected multi-platform index manifest"))
|
|
default:
|
|
return desc, prepErr(fmt.Errorf("unsupported media type %q for OCI index manifest", desc.MediaType))
|
|
}
|
|
}
|
|
// If the server didn't tell us the artifact type it has, then we'll populate
|
|
// the artifact type we _want_ and then the manifest fetch will fail if this
|
|
// doesn't match.
|
|
desc.ArtifactType = ociIndexManifestArtifactType
|
|
return desc, nil
|
|
}
|
|
|
|
func fetchOCIIndexManifest(ctx context.Context, desc ociv1.Descriptor, store OCIRepositoryStore) (*ociv1.Index, error) {
|
|
ctx, span := tracing.Tracer().Start(
|
|
ctx, "Fetch index manifest",
|
|
tracing.SpanAttributes(
|
|
traceattrs.String("oci.manifest.digest", desc.Digest.String()),
|
|
traceattrs.Int64("opentofu.oci.manifest.size", desc.Size),
|
|
),
|
|
)
|
|
defer span.End()
|
|
prepErr := func(err error) error {
|
|
tracing.SetSpanError(span, err)
|
|
return err
|
|
}
|
|
|
|
manifestSrc, err := fetchOCIManifestBlob(ctx, desc, store)
|
|
if err != nil {
|
|
return nil, prepErr(err)
|
|
}
|
|
|
|
var manifest ociv1.Index
|
|
err = json.Unmarshal(manifestSrc, &manifest)
|
|
if err != nil {
|
|
// As an aid to debugging, we'll check whether we seem to have retrieved
|
|
// an image manifest instead of an index manifest, since an unmarshal
|
|
// failure could prevent us from reaching the MediaType check below.
|
|
var manifest ociv1.Manifest
|
|
if err := json.Unmarshal(manifestSrc, &manifest); err == nil && manifest.MediaType == ociv1.MediaTypeImageManifest {
|
|
return nil, prepErr(fmt.Errorf("found image manifest but need index manifest"))
|
|
}
|
|
return nil, prepErr(fmt.Errorf("invalid manifest content: %w", err))
|
|
}
|
|
span.SetAttributes(
|
|
traceattrs.String("opentofu.oci.manifest.media_type", manifest.MediaType),
|
|
traceattrs.String("opentofu.oci.manifest.artifact_type", manifest.ArtifactType),
|
|
)
|
|
|
|
// Now we'll make sure that what we decoded seems vaguely sensible before we
|
|
// return it. Callers are allowed to rely on these checks by verifying
|
|
// that their provided descriptor specifies the wanted media and artifact
|
|
// types before they call this function and then assuming that the result
|
|
// definitely matches what they asked for.
|
|
if manifest.MediaType != desc.MediaType {
|
|
return nil, prepErr(fmt.Errorf("unexpected manifest media type %q", manifest.MediaType))
|
|
}
|
|
if manifest.ArtifactType != desc.ArtifactType {
|
|
return nil, prepErr(fmt.Errorf("unexpected artifact type %q", manifest.ArtifactType))
|
|
}
|
|
// We intentionally leave everything else loose so that we'll have flexibility
|
|
// to extend this format in backward-compatible ways in future OpenTofu versions.
|
|
return &manifest, nil
|
|
}
|
|
|
|
func fetchOCIImageManifest(ctx context.Context, desc ociv1.Descriptor, store OCIRepositoryStore) (*ociv1.Manifest, error) {
|
|
ctx, span := tracing.Tracer().Start(
|
|
ctx, "Fetch platform-specific manifest",
|
|
tracing.SpanAttributes(
|
|
traceattrs.String("oci.manifest.digest", desc.Digest.String()),
|
|
traceattrs.Int64("opentofu.oci.manifest.size", desc.Size),
|
|
),
|
|
)
|
|
defer span.End()
|
|
prepErr := func(err error) error {
|
|
tracing.SetSpanError(span, err)
|
|
return err
|
|
}
|
|
|
|
manifestSrc, err := fetchOCIManifestBlob(ctx, desc, store)
|
|
if err != nil {
|
|
return nil, prepErr(err)
|
|
}
|
|
|
|
var manifest ociv1.Manifest
|
|
err = json.Unmarshal(manifestSrc, &manifest)
|
|
if err != nil {
|
|
// As an aid to debugging, we'll check whether we seem to have retrieved
|
|
// an index manifest instead of an image manifest, since an unmarshal
|
|
// failure could prevent us from reaching the MediaType check below.
|
|
var manifest ociv1.Index
|
|
if err := json.Unmarshal(manifestSrc, &manifest); err == nil && manifest.MediaType == ociv1.MediaTypeImageIndex {
|
|
return nil, prepErr(fmt.Errorf("found index manifest but need image manifest"))
|
|
}
|
|
return nil, prepErr(fmt.Errorf("invalid manifest content: %w", err))
|
|
}
|
|
span.SetAttributes(
|
|
traceattrs.String("opentofu.oci.manifest.media_type", manifest.MediaType),
|
|
traceattrs.String("opentofu.oci.manifest.artifact_type", manifest.ArtifactType),
|
|
)
|
|
|
|
// Now we'll make sure that what we decoded seems vaguely sensible before we
|
|
// return it. Callers are allowed to rely on these checks by verifying
|
|
// that their provided descriptor specifies the wanted media and artifact
|
|
// types before they call this function and then assuming that the result
|
|
// definitely matches what they asked for.
|
|
if manifest.MediaType != desc.MediaType {
|
|
return nil, prepErr(fmt.Errorf("unexpected manifest media type %q", manifest.MediaType))
|
|
}
|
|
if manifest.ArtifactType != desc.ArtifactType {
|
|
return nil, prepErr(fmt.Errorf("unexpected artifact type %q", manifest.ArtifactType))
|
|
}
|
|
// We intentionally leave everything else loose so that we'll have flexibility
|
|
// to extend this format in backward-compatible ways in future OpenTofu versions.
|
|
return &manifest, nil
|
|
}
|
|
|
|
func selectOCIImageManifest(descs []ociv1.Descriptor, provider addrs.Provider, version versions.Version, target Platform) (ociv1.Descriptor, error) {
|
|
foundManifests := 0
|
|
foundWrongArtifactType := ""
|
|
foundWrongPlatform := 0
|
|
var selected ociv1.Descriptor
|
|
for _, desc := range descs {
|
|
if desc.ArtifactType != ociPackageManifestArtifactType {
|
|
if desc.ArtifactType != "" {
|
|
foundWrongArtifactType = desc.ArtifactType
|
|
}
|
|
continue // silently ignore anything that isn't claiming to be a provider package manifest
|
|
}
|
|
if desc.MediaType != ociv1.MediaTypeImageManifest {
|
|
// If this descriptor claims to be for a provider target manifest then
|
|
// it MUST be declared as being an image manifest.
|
|
return selected, fmt.Errorf("provider image manifest has unsupported media type %q", desc.MediaType)
|
|
}
|
|
if desc.Platform == nil {
|
|
return selected, fmt.Errorf("provider image manifest lacks the required platform constraints")
|
|
}
|
|
if desc.Platform.OSVersion != "" || desc.Platform.OS != target.OS || desc.Platform.Architecture != target.Arch {
|
|
// We ignore manifests that aren't for the platform we're trying to match.
|
|
// We also ignore manifests that specify a specific OS version because we
|
|
// don't currently have any means to handle that, but we want to give
|
|
// future OpenTofu versions the option of treating that as a more specific
|
|
// match while leaving an OSVersion-free entry as a compatibility fallback.
|
|
foundWrongPlatform++
|
|
continue
|
|
}
|
|
// We have found a plausible candidate!
|
|
foundManifests++
|
|
selected = desc
|
|
}
|
|
if foundManifests == 0 {
|
|
switch {
|
|
case foundWrongPlatform > 0:
|
|
// If all of the manifests were valid but none were eligible for this
|
|
// platform then we assume that this is a valid provider that just
|
|
// lacks support for the current platform, for which we have a special
|
|
// error type.
|
|
return selected, ErrPlatformNotSupported{
|
|
Provider: provider,
|
|
Version: version,
|
|
Platform: target,
|
|
}
|
|
case foundWrongArtifactType != "":
|
|
// If we didn't find any manifests with the correct artifact type
|
|
// targeting _any_ platform but we found at least one with a
|
|
// different artifact type then we'll mention that here. We
|
|
// arbitrarily just take whichever incorrect artifact type we
|
|
// found most recently, since it's unlikely to get here so not
|
|
// worth the complexity of trying to collect multiple.
|
|
return selected, fmt.Errorf("provider image manifest has unsupported artifact type %q", foundWrongArtifactType)
|
|
default:
|
|
// This is a particularly annoying case: none of the declared
|
|
// manifests have any artifact type _at all_. The most likely
|
|
// cause of this is that the user has accidentally selected the
|
|
// index manifest for a multi-platform container image, and so
|
|
// we'll bias toward that explanation in the error message but
|
|
// present it as a question to communicate that we're not sure.
|
|
return selected, fmt.Errorf("provider image manifest has unsupported artifact type; is this a container image, rather than an OpenTofu provider?")
|
|
}
|
|
}
|
|
if foundManifests > 1 {
|
|
// There must be exactly one eligible manifest, to avoid ambiguity.
|
|
return selected, fmt.Errorf("ambiguous manifest has multiple descriptors for platform %s", target)
|
|
}
|
|
return selected, nil
|
|
}
|
|
|
|
func selectOCILayerBlob(descs []ociv1.Descriptor) (ociv1.Descriptor, error) {
|
|
foundBlobs := 0
|
|
foundWrongMediaTypeBlobs := 0
|
|
var selected ociv1.Descriptor
|
|
for _, desc := range descs {
|
|
if desc.MediaType != ociPackageMediaType {
|
|
// We silently ignore any "layer" that doesn't have both our expected
|
|
// artifact type and media type so that future versions of OpenTofu
|
|
// can potentially support additional archive formats, and so that
|
|
// artifact authors can include other non-OpenTofu-related layers
|
|
// in their manifests if needed... but we do still count them so that
|
|
// we can hint about it in an error message below.
|
|
foundWrongMediaTypeBlobs++
|
|
continue
|
|
}
|
|
foundBlobs++
|
|
selected = desc
|
|
}
|
|
if foundBlobs == 0 {
|
|
if foundWrongMediaTypeBlobs > 0 {
|
|
return selected, fmt.Errorf("image manifest contains no layers of type %q, but has other unsupported formats; this OCI artifact might be intended for a different version of OpenTofu", ociPackageMediaType)
|
|
}
|
|
return selected, fmt.Errorf("image manifest contains no layers of type %q", ociPackageMediaType)
|
|
}
|
|
if foundBlobs > 1 {
|
|
// There must be exactly one eligible blob, to avoid ambiguity.
|
|
return selected, fmt.Errorf("ambiguous manifest declares multiple eligible provider packages")
|
|
}
|
|
return selected, nil
|
|
}
|
|
|
|
func fetchOCIManifestBlob(ctx context.Context, desc ociv1.Descriptor, store OCIRepositoryStore) ([]byte, error) {
|
|
// We impose a size limit on the manifest just to avoid an abusive remote registry
|
|
// occupuing unbounded memory when we read the manifest content into memory below.
|
|
if (desc.Size / 1024 / 1024) > ociImageManifestSizeLimitMiB {
|
|
return nil, fmt.Errorf("manifest size exceeds OpenTofu's size limit of %d MiB", ociImageManifestSizeLimitMiB)
|
|
}
|
|
|
|
readCloser, err := store.Fetch(ctx, desc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer readCloser.Close()
|
|
manifestReader := io.LimitReader(readCloser, desc.Size)
|
|
|
|
// We need to verify that the content matches the digest in the descriptor,
|
|
// and we also need to parse that data as JSON. We can only read from the
|
|
// reader once, so we have no choice but to buffer it all in memory.
|
|
manifestSrc, err := io.ReadAll(manifestReader)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading manifest content: %w", err)
|
|
}
|
|
|
|
gotDigest := desc.Digest.Algorithm().FromBytes(manifestSrc)
|
|
if gotDigest != desc.Digest {
|
|
return nil, fmt.Errorf("manifest content does not match digest %s", desc.Digest)
|
|
}
|
|
|
|
return manifestSrc, nil
|
|
}
|