Files
opentf/internal/getproviders/package_location_oci_blob_archive.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

306 lines
13 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"
"fmt"
"io"
"os"
"path/filepath"
"slices"
"github.com/hashicorp/go-getter"
ociDigest "github.com/opencontainers/go-digest"
ociv1 "github.com/opencontainers/image-spec/specs-go/v1"
orasContent "oras.land/oras-go/v2/content"
"github.com/opentofu/opentofu/internal/tracing"
"github.com/opentofu/opentofu/internal/tracing/traceattrs"
)
// ociPackageMediaType is the specific media type we're expecting for the blob
// representing a final distribution package that we'll fetch and extract, after
// we've dug through all of the manifests.
//
// We currently pay attention only to blobs which have both this media type AND
// the artifact type in [ociPackageArtifactType], silently ignoring everything else,
// so that future versions of OpenTofu can potentially support provider packages
// using different archive formats.
const ociPackageMediaType = "archive/zip"
// PackageOCIBlobArchive represents a provider package archive stored as a blob in
// an OCI Distribution repository.
//
// A [Source] returning this kind of location must first choose the appropriate
// artifact from a provider's multi-platform index manifest, fetch that manifest,
// and identify the single leaf blob whose content is the provider package
// archive. This type does not interact with artifact manifests at all, expecting
// that whatever constructed it has already interrogated all of the relevant
// manifests.
type PackageOCIBlobArchive struct {
// Unlike the other PackageLocation types, this one has its internals unexported
// so that we can make use of ORAS-Go types as an implementation detail without
// exposing that decision in this package's public API.
// repoStore is the ORAS storage that the artifact should be fetched from.
//
// In normal use this will typically be an instance of ORAS-Go's remote.Repository
// type representing a specific remote repository, but it's also valid to use
// a local filesystem or in-memory representation e.g. in unit tests.
repoStore orasContent.Fetcher
// registryDomain and repositoryName are UI-display-only values together describing
// which repository the repoStore object is configured to access, so that we can
// describe what we're downloading in a human-friendly way.
//
// registryDomain is empty if repoStore is not for a remote repository. In that case
// repositoryName might be a filesystem path if using a local OCI layout directory/archive,
// or also empty if it's an in-memory-only store as we use in some tests.
registryDomain, repositoryName string
// blobDescriptor is the in-memory representation of an OCI descriptor
// for the leaf blob to retrieve.
//
// We currently require that the digest in the descriptor use the "sha256"
// algorithm in particular, because that is directly analogous to
// our [HashSchemeZip] and thus we can cross-verify the archive against
// any signed checksums provided by the provider author from the provider's
// origin registry that were recorded into the dependency lock file after
// an earlier install from the origin registry.
//
// The MediaType and ArtifactType fields must represent valid selections for a
// provider package stored in an OCI Distribution repository. Currently that
// means that MediaType must be "archive/zip" and ArtifactType must be
// "application/vnd.opentofu.providerpkg"; future OpenTofu formats might support
// other file formats, which can be represented by choosing a new value of
// MediaType while retaining the same ArtifactType.
blobDescriptor ociv1.Descriptor
}
var _ PackageLocation = PackageOCIBlobArchive{}
func (p PackageOCIBlobArchive) InstallProviderPackage(ctx context.Context, meta PackageMeta, targetDir string, allowedHashes []Hash) (*PackageAuthenticationResult, error) {
pkgDesc := p.blobDescriptor
ctx, span := tracing.Tracer().Start(
ctx, "Fetch provider package",
tracing.SpanAttributes(
traceattrs.OpenTofuOCIRegistryDomain(p.registryDomain),
traceattrs.OpenTofuOCIRepositoryName(p.repositoryName),
traceattrs.OpenTofuOCIBlobDigest(pkgDesc.Digest.String()),
traceattrs.OpenTofuOCIBlobMediaType(pkgDesc.MediaType),
traceattrs.OpenTofuOCIBlobSize(pkgDesc.Size),
traceattrs.String("opentofu.provider.local_dir", targetDir),
traceattrs.OpenTofuProviderAddress(meta.Provider.String()),
traceattrs.OpenTofuProviderVersion(meta.Version.String()),
traceattrs.OpenTofuTargetPlatform(meta.TargetPlatform.String()),
),
)
defer span.End()
prepErr := func(err error) error {
tracing.SetSpanError(span, err)
return err
}
// First we'll make sure that what we've been given makes sense to be the descriptor
// for a provider package blob. A failure here suggests a bug in the [Source] that
// generated this location object, since it should not select a blob that this
// location type cannot support.
err := checkOCIBlobDescriptor(pkgDesc, meta)
if err != nil {
return nil, prepErr(err)
}
// If we have a fixed set of allowed hashes then we'll check that our
// selected descriptor matches before we waste time fetching the package.
if len(allowedHashes) > 0 && !ociPackageDescriptorDigestMatchesAnyHash(pkgDesc.Digest, allowedHashes) {
return nil, prepErr(fmt.Errorf(
// FIXME: We currently have slightly-different variations of this error
// message spread across the different [PackageLocation] implementations.
// It would be good to settle on a single good version of this text,
// centralize it into a constant or function somewhere, and then reuse
// it across all of the package location types. For now though, this
// matches some of the others so we can treat that refactoring as a
// separate task from implementing OCI-based installation as a new feature.
"the current package for %s %s doesn't match any of the checksums previously recorded in the dependency lock file; for more information: https://opentofu.org/docs/language/files/dependency-lock/#checksum-verification",
meta.Provider, meta.Version,
))
}
// If we reach this point then we have a descriptor for what will hopefully
// turn out to be a valid provider package blob, and so we'll download it
// into a temporary file from which we can verify it and then extract it
// into its final location.
localLoc, err := fetchOCIBlobToTemporaryFile(ctx, pkgDesc, p.repoStore)
if err != nil {
return nil, prepErr(fmt.Errorf("fetching provider package blob %s: %w", pkgDesc.Digest.String(), err))
}
defer os.Remove(string(localLoc)) // Best effort to remove the temporary file before we return
// We'll now delegate the final installation step to the localLoc object,
// which knows how to extract the temporary archive into the target
// directory and verify/authenticate it.
// To do that we need a slightly-modified "meta" that refers to the
// local location instead of the remote one. (This redundancy is awkward,
// but is a historical artifact of how package installation used to be
// separated from the PackageLocation type. We're accepting that awkwardness
// for now to avoid a risky refactor, but hopefully we'll tidy this up
// someday.)
localMeta := PackageMeta{
Provider: meta.Provider,
Version: meta.Version,
ProtocolVersions: meta.ProtocolVersions,
TargetPlatform: meta.TargetPlatform,
Filename: meta.Filename,
Location: localLoc,
Authentication: meta.Authentication,
}
authResult, err := localLoc.InstallProviderPackage(ctx, localMeta, targetDir, allowedHashes)
return authResult, prepErr(err)
}
func (p PackageOCIBlobArchive) String() string {
switch {
case p.registryDomain != "" && p.repositoryName != "":
// This is the main case for real end-user use, where we should
// always be talking to a repository in a remote registry.
return fmt.Sprintf(
"%s/%s@%s",
p.registryDomain,
p.repositoryName,
p.blobDescriptor.Digest.String(),
)
case p.repositoryName != "":
// A local-filesystem-based repository is not currently something
// we support as an end-user-facing feature, so this situation is
// currently just for testing purposes.
return fmt.Sprintf(
"./%s@%s", // ./ prefix just to distinguish this from a remote registry address
filepath.Clean(p.repositoryName),
p.blobDescriptor.Digest.String(),
)
default:
// If we don't have either of the human-oriented location fields
// populated then we'll just use the digest, and hope that the
// reader knows what store we're using. This situation should
// arise only in unit tests.
return p.blobDescriptor.Digest.String()
}
}
// fetchOCIBlobToTemporaryFile uses the given ORAS fetcher to pull the content of the
// blob described by "desc" into a temporary file on the local filesystem, and
// then returns the path to that file as a [PackageLocalArchive] that can be used
// to delegate the final verification and installation of that archive into a
// target directory.
//
// It is the caller's responsibility to delete the temporary file once it's no longer
// needed.
func fetchOCIBlobToTemporaryFile(ctx context.Context, desc ociv1.Descriptor, store orasContent.Fetcher) (loc PackageLocalArchive, err error) {
// This is effectively an OCI Distribution equivalent of the similar technique
// used for PackageHTTPArchive.
// We'll eventually need to generate an OpenTofu-style hash for this package anyway,
// so we'll do that now to make sure we have a valid digest before we try to
// download anything.
wantHash, err := hashFromOCIDigest(desc.Digest)
if err != nil {
return PackageLocalArchive(""), fmt.Errorf("cannot verify package contents: %w", err)
}
readCloser, err := store.Fetch(ctx, desc)
if err != nil {
return PackageLocalArchive(""), err
}
defer readCloser.Close()
reader := io.LimitReader(readCloser, desc.Size)
f, err := os.CreateTemp("", "opentofu-provider")
if err != nil {
return PackageLocalArchive(""), fmt.Errorf("failed to open temporary file: %w", err)
}
loc = PackageLocalArchive(f.Name())
defer func() {
// If we're returning an error then the caller won't make use of the
// file we've created, so we'll make a best effort to proactively
// remove it. If we succeed then it's the caller's responsibility to
// remove the file once it's no longer needed.
f.Close() // always close this file, so that caller can safely remove it.
if err != nil {
os.Remove(f.Name())
}
}()
// We'll borrow go-getter's "cancelable copy" implementation here so that
// the download can potentially be interrupted partway through.
n, err := getter.Copy(ctx, f, reader)
if err == nil && n < desc.Size {
// This should be impossible because we used io.LimitReader, but we'll check
// anyway to be robust since go-getter returns this information regardless.
err = fmt.Errorf("incorrect response size: expected %d bytes, but got %d bytes", desc.Size, n)
}
if err != nil {
return loc, err
}
// Before we return we'll make sure that the file we've just created matches
// the digest we were given for it, since the ORAS fetcher doesn't do that
// automatically itself.
matchesHash, err := PackageMatchesHash(loc, wantHash)
if err != nil {
return loc, fmt.Errorf("cannot verify package contents: %w", err)
}
if !matchesHash {
return loc, fmt.Errorf("provider package does not match digest %s", desc.Digest)
}
// Everything seems okay, so we'll let the caller take ownership of the temporary file.
return loc, nil
}
func ociPackageDescriptorDigestMatchesAnyHash(found ociDigest.Digest, allowed []Hash) bool {
foundHash, err := hashFromOCIDigest(found)
if err != nil {
// An unsupported or invalid digest cannot possibly match
return false
}
return slices.Contains(allowed, foundHash)
}
func hashFromOCIDigest(digest ociDigest.Digest) (Hash, error) {
if err := digest.Validate(); err != nil {
return Hash(""), fmt.Errorf("invalid digest %q: %w", digest.String(), err)
}
if algo := digest.Algorithm(); algo != ociDigest.SHA256 {
// OpenTofu's "ziphash" format requires a SHA256 checksum in particular.
return Hash(""), fmt.Errorf("unsupported digest algorithm %q", algo.String())
}
// If we have a valid sha256 digest then we can use its payload
// directly as the payload for our "ziphash" checksum scheme,
// because both we and OCI use lowercase hex encoding for these.
return HashSchemeZip.New(digest.Encoded()), nil
}
func checkOCIBlobDescriptor(desc ociv1.Descriptor, meta PackageMeta) error {
if desc.MediaType != ociPackageMediaType {
return fmt.Errorf("selected OCI artifact manifest has unexpected media type %q", desc.MediaType)
}
if desc.Platform != nil {
// A descriptor can optionally by annotated by a platform selection. We don't
// require this because the top-level index manifest for a provider artifact
// is enough of a signal of this information, but if someone has gone to the
// trouble of annotating their blob descriptor with a platform then we'll
// use that to generate a more useful error message than is likely to occur
// if we later try to run an executable intended for a different platform.
if desc.Platform.OS != meta.TargetPlatform.OS || desc.Platform.Architecture != meta.TargetPlatform.Arch {
// We'll use our own type here to produce an OpenTofu-conventional string representation
gotPlatform := Platform{OS: desc.Platform.OS, Arch: desc.Platform.Architecture}
return fmt.Errorf("selected OCI artifact is for %s, but was expected to be for %s", gotPlatform.String(), meta.TargetPlatform.String())
}
}
return nil
}