// 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 "" 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 " 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 }