Files
opentf/internal/getproviders/oci_registry_mirror_source.go
Martin Atkins 93d095c67e traceattrs: Functions for the commonly-used OCI-related attributes
We have a number of trace attributes that we use across all of our OCI
Distribution-based functionality, so this centralizes their definitions
in package traceattrs.

This intentionally ignores a few additional attribute names that are used
only in the code that interacts with Docker-style credential helpers,
because all of those are used only in a single function and so adding
indirection for those doesn't have enough benefit to offset the cost of
additional indirection.

Signed-off-by: Martin Atkins <mart@degeneration.co.uk>
2025-11-03 11:41:50 -08:00

778 lines
34 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.OCIManifestDigest(desc.Digest.String()),
traceattrs.OpenTofuOCIReferenceTag(tagName),
traceattrs.OpenTofuOCIManifestMediaType(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.OpenTofuOCIManifestArtifactType(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.OCIManifestDigest(desc.Digest.String()),
traceattrs.OpenTofuOCIManifestSize(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.OpenTofuOCIManifestMediaType(manifest.MediaType),
traceattrs.OpenTofuOCIManifestArtifactType(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.OCIManifestDigest(desc.Digest.String()),
traceattrs.OpenTofuOCIManifestSize(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.OpenTofuOCIManifestMediaType(manifest.MediaType),
traceattrs.OpenTofuOCIManifestArtifactType(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
}