Files
opentf/internal/getmodules/oci_getter_test.go
Martin Atkins 84bcaf74eb getmodules: A new "getter" for OCI repositories
This is a new implementation of go-getter's "Getter" interface intended
to support installing OpenTofu module packages from OCI Distribution
repositories.

Since this is currently only intended for OpenTofu's use it makes some
simplifying assumptions that would not be acceptable for an upstream
getter, but are okay for the limited way that OpenTofu's module installer
uses go-getter, which is already intentionally constrained and hidden
behind a simpler API so we can treat go-getter as purely an implementation
detail.

This commit only introduces the getter, without actually registering it
as available for use in the module package fetcher used by "tofu init",
and so this is effectively just a bunch of dead code. A later commit will
wire this in properly and introduce an end-to-end test to demonstrate that
it's properly integrated.

Signed-off-by: Martin Atkins <mart@degeneration.co.uk>
2025-04-23 16:34:57 -07:00

352 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 getmodules
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"testing"
"github.com/hashicorp/go-getter"
ociDigest "github.com/opencontainers/go-digest"
ociSpecs "github.com/opencontainers/image-spec/specs-go"
ociv1 "github.com/opencontainers/image-spec/specs-go/v1"
orasContent "oras.land/oras-go/v2/content"
orasMemoryStore "oras.land/oras-go/v2/content/memory"
)
func TestGetterDecompressorsConsistent(t *testing.T) {
// This test makes sure that the following three variables are
// all defined consistently enough with one another to satisfy
// the assumptions that ociDistributionGetter makes about them:
// - goGetterDecompressors
// - goGetterDecompressorMediaTypes
// - ociBlobMediaTypePreference
// Assumption 1: all entries in goGetterDecompressorMediaTypes have
// a corresponding entry in goGetterDecompressors.
for k, v := range goGetterDecompressorMediaTypes {
_, ok := goGetterDecompressors[v]
if !ok {
t.Errorf("goGetterDecompressorMediaTypes[%q] refers to %q, which is not defined in goGetterDecompressors", k, v)
}
}
// Assumption 2: every entry in goGetterDecompressorMediaTypes is
// included somewhere in ociBlobMediaTypePreference, so that we
// know which media type to prefer when multiple are present.
if lenMT, lenPref := len(goGetterDecompressorMediaTypes), len(ociBlobMediaTypePreference); lenMT != lenPref {
t.Errorf("goGetterDecompressorMediaTypes has %d elements, but ociBlobMediaTypePreference has %d; should be equal length", lenMT, lenPref)
}
for _, v := range ociBlobMediaTypePreference {
_, ok := goGetterDecompressorMediaTypes[v]
if !ok {
t.Errorf("ociBlobMediaTypePreference includes %q, which is not present in goGetterDecompressorMediaTypes", v)
}
}
}
func TestOCIDistributionGetter(t *testing.T) {
// For this test we'll use an in-memory-only repository store implementation,
// although we need to use a local wrapper to work around a leaky abstraction
// where the in-memory store doesn't behave quite the same as a real OCI
// Distribution registry client would. :(
//
// In real use ociDistributionGetter is more likely to be used with ORAS-Go's
// remote registry client implementation, but that's the caller's responsibility
// to decide if so.
mainStore := digestResolvingInMemoryOCIStore{
orasMemoryStore.New(),
}
// We'll build some fake-but-valid module packages to put in this store so
// that we can test various valid source address inputs.
latestBlobDesc := ociPushFakeModulePackageBlob(t, "content of latest", mainStore)
latestManifestDesc := ociPushFakeImageManifest(t, latestBlobDesc, ociIndexManifestArtifactType, mainStore)
ociCreateTag(t, "latest", latestManifestDesc, mainStore)
fooBlobDesc := ociPushFakeModulePackageBlob(t, "content of foo", mainStore)
fooManifestDesc := ociPushFakeImageManifest(t, fooBlobDesc, ociIndexManifestArtifactType, mainStore)
ociCreateTag(t, "foo", fooManifestDesc, mainStore)
digestBlobDesc := ociPushFakeModulePackageBlob(t, "content of digest-only reference", mainStore)
digestManifestDesc := ociPushFakeImageManifest(t, digestBlobDesc, ociIndexManifestArtifactType, mainStore)
digestManifestDigestStr := digestManifestDesc.Digest.String()
// We'll log the digests of the three manifests we're going to use in
// the tests below just in case they appear as part of error messages,
// so we can understand what failed.
t.Logf("'latest' tag\nmanifest: %s\nblob: %s", latestManifestDesc.Digest, latestBlobDesc.Digest)
t.Logf("'foo' tag\nmanifest: %s\nblob: %s", fooManifestDesc.Digest, fooBlobDesc.Digest)
t.Logf("untagged manifest\nmanifest: %s\nblob: %s", digestManifestDigestStr, digestBlobDesc.Digest)
ociGetter := &ociDistributionGetter{
getOCIRepositoryStore: func(ctx context.Context, registryDomain, repositoryName string) (OCIRepositoryStore, error) {
if registryDomain != "example.com" {
return nil, fmt.Errorf("no such registry")
}
switch repositoryName {
case "main":
return mainStore, nil
case "empty":
// We'll just return a completely empty store for this one
return orasMemoryStore.New(), nil
default:
return nil, fmt.Errorf("no such repository")
}
},
}
tests := []struct {
source string
wantFileContent string
wantError string
}{
{
source: "oci://example.com/main",
wantFileContent: `content of latest`,
},
{
source: "oci://example.com/main?tag=foo",
wantFileContent: `content of foo`,
},
{
// NOTE: This particular test is currently relying on the workaround
// applied by our store wrapper type [digestResolvingInMemoryOCIStore],
// because the upstream ORAS-Go in-memory store does not implement
// "Resolve" realistically as compared to a registry client implementation.
source: "oci://example.com/main?digest=" + digestManifestDigestStr,
wantFileContent: `content of digest-only reference`,
},
// Various failure cases
{
source: "oci://nonexist.example.com/boop",
wantError: `error downloading 'oci://nonexist.example.com/boop': configuring client for nonexist.example.com/boop: no such registry`,
},
{
source: "oci://example.com/in$valid", // invalid repository name syntax, per OCI Distribution spec
wantError: `error downloading 'oci://example.com/in$valid': invalid reference: invalid repository "in$valid"`,
},
{
source: "oci://example.com/empty",
wantError: `error downloading 'oci://example.com/empty': resolving tag "latest": not found`,
},
{
source: "oci://example.com/empty?tag=baz",
wantError: `error downloading 'oci://example.com/empty?tag=baz': resolving tag "baz": not found`,
},
{
source: "oci://example.com/empty?tag=in$valid", // invalid tag name syntax, per OCI distribution spec
wantError: `error downloading 'oci://example.com/empty?tag=in%24valid': invalid reference: invalid tag "in$valid"`,
},
{
source: "oci://example.com/empty?digest=sha256:1d57d25084effd3fdfd902eca00020b34b1fb020253b84d7dd471301606015ac",
wantError: `error downloading 'oci://example.com/empty?digest=sha256%3A1d57d25084effd3fdfd902eca00020b34b1fb020253b84d7dd471301606015ac': resolving digest "sha256:1d57d25084effd3fdfd902eca00020b34b1fb020253b84d7dd471301606015ac": not found`,
},
{
source: "oci://example.com/empty?tag=",
wantError: `error downloading 'oci://example.com/empty?tag=': tag argument must not be empty`,
},
{
source: "oci://example.com/empty?tag=foo&tag=bar",
wantError: `error downloading 'oci://example.com/empty?tag=foo&tag=bar': too many "tag" arguments`,
},
{
source: "oci://example.com/empty?digest=nope",
wantError: `error downloading 'oci://example.com/empty?digest=nope': invalid digest: invalid checksum digest format`,
},
{
source: "oci://example.com/empty?digest=nope:nope",
wantError: `error downloading 'oci://example.com/empty?digest=nope%3Anope': invalid digest: unsupported digest algorithm`,
},
{
source: "oci://example.com/empty?digest=",
wantError: `error downloading 'oci://example.com/empty?digest=': digest argument must not be empty`,
},
{
source: "oci://example.com/empty?digest=nope&digest=nope",
wantError: `error downloading 'oci://example.com/empty?digest=nope&digest=nope': too many "digest" arguments`,
},
{
source: "oci://example.com/empty?tag=foo&digest=sha256:1d57d25084effd3fdfd902eca00020b34b1fb020253b84d7dd471301606015ac",
wantError: `error downloading 'oci://example.com/empty?digest=sha256%3A1d57d25084effd3fdfd902eca00020b34b1fb020253b84d7dd471301606015ac&tag=foo': cannot set both "tag" and "digest" arguments`,
},
{
source: "oci://example.com/empty?tag=foo&other=bar",
wantError: `error downloading 'oci://example.com/empty?other=bar&tag=foo': unsupported argument "other"`,
},
{
source: "oci://example.com/empty?archive=zip",
wantError: `the "archive" argument is not allowed for OCI sources, because the archive format is detected automatically from the image manifest`,
},
}
for _, test := range tests {
t.Run(test.source, func(t *testing.T) {
instPath := t.TempDir()
client := getter.Client{
Src: test.source,
Dst: instPath,
Pwd: instPath,
Mode: getter.ClientModeDir,
Detectors: goGetterNoDetectors,
Getters: map[string]getter.Getter{
"oci": ociGetter,
},
Ctx: t.Context(),
}
err := client.Get()
if test.wantError != "" {
if err == nil {
t.Fatalf("unexpected success\nwant error: %s", test.wantError)
}
if got := err.Error(); got != test.wantError {
t.Fatalf("unexpected error\ngot: %s\nwant: %s", got, test.wantError)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if test.wantFileContent == "" {
return // no file content test required
}
gotContentRaw, err := os.ReadFile(filepath.Join(instPath, "test_content.txt"))
if err != nil {
t.Fatal(err)
}
gotContent := string(bytes.TrimSpace(gotContentRaw))
if gotContent != test.wantFileContent {
t.Errorf("wrong file content after successful install\ngot: %s\nwant: %s", gotContent, test.wantFileContent)
}
})
}
}
func ociPushFakeModulePackageBlob(t *testing.T, fakeContent string, store orasContent.Pusher) ociv1.Descriptor {
t.Helper()
var buf bytes.Buffer
zr := zip.NewWriter(&buf)
fw, err := zr.Create("test_content.txt")
if err != nil {
t.Fatalf("can't create file in fake module package: %s", err)
}
n, err := io.WriteString(fw, fakeContent)
if err != nil {
t.Fatalf("can't write to file in fake module package: %s", err)
}
if n != len(fakeContent) {
t.Fatalf("incomplete write of fake content")
}
zr.Close()
desc := ociv1.Descriptor{
MediaType: "archive/zip",
Digest: ociDigest.FromBytes(buf.Bytes()),
Size: int64(buf.Len()),
}
err = store.Push(t.Context(), desc, &buf)
if err != nil {
t.Fatalf("can't push blob to store: %s", err)
}
return desc
}
func ociPushFakeImageManifest(t *testing.T, layerDesc ociv1.Descriptor, artifactType string, store orasContent.Pusher) ociv1.Descriptor {
t.Helper()
manifest := &ociv1.Manifest{
Versioned: ociSpecs.Versioned{
SchemaVersion: 2,
},
MediaType: ociv1.MediaTypeImageManifest,
ArtifactType: artifactType,
Config: ociv1.DescriptorEmptyJSON,
Layers: []ociv1.Descriptor{layerDesc},
}
manifestSrc, err := json.Marshal(manifest)
if err != nil {
t.Errorf("can't serialize manifest: %s", err)
}
desc := ociv1.Descriptor{
MediaType: manifest.MediaType,
ArtifactType: manifest.ArtifactType,
Digest: ociDigest.FromBytes(manifestSrc),
Size: int64(len(manifestSrc)),
}
err = store.Push(t.Context(), desc, bytes.NewReader(manifestSrc))
if err != nil {
t.Fatalf("can't push manifest to store: %s", err)
}
return desc
}
func ociCreateTag(t *testing.T, tagName string, desc ociv1.Descriptor, store orasContent.Tagger) {
t.Helper()
err := store.Tag(t.Context(), desc, tagName)
if err != nil {
t.Fatalf("can't create tag %q: %s", tagName, err)
}
}
// digestResolvingInMemoryOCIStore is a moderately-ugly hack to make
// ORAS-Go's in memory store behave slightly more like a realistic
// OCI Distribution registry server by allowing the resolution of
// raw digests into descriptors, whereas the upstream in-memory
// implementation only allows resolving tags.
//
// It's unfortunate that the various ORAS-Go "fake" implementations
// are not realistic as compared to a real registry, but this minor
// concession allows us to avoid all of the pain of running a _real_
// registry server to support our unit tests.
//
// (We also use an end-to-end test in the command/e2etest package
// that exercises similar behavior with ORAS-Go's real registry
// client implementation, to give us some insurance that this
// workaround stays realistic enough.)
type digestResolvingInMemoryOCIStore struct {
*orasMemoryStore.Store
}
var _ OCIRepositoryStore = digestResolvingInMemoryOCIStore{}
func (s digestResolvingInMemoryOCIStore) Push(ctx context.Context, expected ociv1.Descriptor, content io.Reader) error {
// First we'll delegate to the upstream implementation to get the blob
// actually saved in the store.
err := s.Store.Push(ctx, expected, content)
if err != nil {
return err
}
// If storage was successful _and_ if the descriptor suggests that this
// was intended to be a manifest blob then we'll create a fake "tag"
// whose name matches the digest, which is just enough to trick
// the "Resolve" method into handling the lookup the same way a real
// OCI Registry client would handle it.
if expected.MediaType == ociv1.MediaTypeImageManifest {
err = s.Store.Tag(ctx, expected, expected.Digest.String())
if err != nil {
return fmt.Errorf("while creating a weird tag to fake looking up by digest: %w", err)
}
}
return nil
}