Add @sourceIndex syntax to oci/layout

Images in the index can now be referenced via the @sourceIndex syntax.

Signed-off-by: Miloslav Trmač <mitr@redhat.com>
Signed-off-by: Valentin Rothberg <vrothberg@redhat.com>
This commit is contained in:
Miloslav Trmač 2024-09-10 20:22:47 +02:00 committed by Valentin Rothberg
parent 16e3aee517
commit 71e849a6b1
7 changed files with 270 additions and 42 deletions

View File

@ -71,13 +71,15 @@ An image stored in the docker daemon's internal storage.
The image must be specified as a _docker-reference_ or in an alternative _algo_`:`_digest_ format when being used as an image source.
The _algo_`:`_digest_ refers to the image ID reported by docker-inspect(1).
### **oci:**_path_[`:`_reference_]
### **oci:**_path_[`:`{_reference_|`@`_source-index_}]
An image in a directory structure compliant with the "Open Container Image Layout Specification" at _path_.
The _path_ value terminates at the first `:` character; any further `:` characters are not separators, but a part of _reference_.
The _reference_ is used to set, or match, the `org.opencontainers.image.ref.name` annotation in the top-level index.
If _reference_ is not specified when reading an image, the directory must contain exactly one image.
For reading images, @_source-index_ is a zero-based index in manifest (to access untagged images).
If neither reference nor @_source_index is specified when reading an image, the path must contain exactly one image.
### **oci-archive:**_path_[`:`_reference_]

View File

@ -6,6 +6,7 @@ import (
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
)
@ -119,3 +120,31 @@ func validateScopeNonWindows(scope string) error {
return nil
}
// parseOCIReferenceName parses the image from the oci reference.
func parseOCIReferenceName(image string) (img string, index int, err error) {
index = -1
if strings.HasPrefix(image, "@") {
idx, err := strconv.Atoi(image[1:])
if err != nil {
return "", index, fmt.Errorf("Invalid source index @%s: not an integer: %w", image[1:], err)
}
if idx < 0 {
return "", index, fmt.Errorf("Invalid source index @%d: must not be negative", idx)
}
index = idx
} else {
img = image
}
return img, index, nil
}
// ParseReferenceIntoElements splits the oci reference into location, image name and source index if exists
func ParseReferenceIntoElements(reference string) (string, string, int, error) {
dir, image := SplitPathAndImage(reference)
image, index, err := parseOCIReferenceName(image)
if err != nil {
return "", "", -1, err
}
return dir, image, index, nil
}

View File

@ -18,6 +18,12 @@ type testDataScopeValidation struct {
errMessage string
}
type testOCIReference struct {
ref string
image string
index int
}
func TestSplitReferenceIntoDirAndImageWindows(t *testing.T) {
tests := []testDataSplitReference{
{`C:\foo\bar:busybox:latest`, `C:\foo\bar`, "busybox:latest"},
@ -61,3 +67,25 @@ func TestValidateScopeWindows(t *testing.T) {
}
}
}
func TestParseOCIReferenceName(t *testing.T) {
validTests := []testOCIReference{
{"@0", "", 0},
{"notlatest@1", "notlatest@1", -1},
}
for _, test := range validTests {
img, idx, err := parseOCIReferenceName(test.ref)
assert.NoError(t, err)
assert.Equal(t, img, test.image)
assert.Equal(t, idx, test.index)
}
invalidTests := []string{
"@-5",
"@invalidIndex",
}
for _, test := range invalidTests {
_, _, err := parseOCIReferenceName(test)
assert.Error(t, err)
}
}

View File

@ -38,6 +38,9 @@ type ociImageDestination struct {
// newImageDestination returns an ImageDestination for writing to an existing directory.
func newImageDestination(sys *types.SystemContext, ref ociReference) (private.ImageDestination, error) {
if ref.sourceIndex != -1 {
return nil, fmt.Errorf("Destination reference must not contain a manifest index @%d", ref.sourceIndex)
}
var index *imgspecv1.Index
if indexExists(ref) {
var err error

View File

@ -32,7 +32,7 @@ func TestPutBlobDigestFailure(t *testing.T) {
const digestErrorString = "Simulated digest error"
const blobDigest = "sha256:e692418e4cbaf90ca69d05a66403747baa33ee08806650b51fab815ad7fc331f"
ref, _ := refToTempOCI(t)
ref, _ := refToTempOCI(t, false)
dirRef, ok := ref.(ociReference)
require.True(t, ok)
blobPath, err := dirRef.blobPath(blobDigest, "")
@ -71,7 +71,7 @@ func TestPutBlobDigestFailure(t *testing.T) {
// TestPutManifestAppendsToExistingManifest tests that new manifests are getting added to existing index.
func TestPutManifestAppendsToExistingManifest(t *testing.T) {
ref, tmpDir := refToTempOCI(t)
ref, tmpDir := refToTempOCI(t, false)
ociRef, ok := ref.(ociReference)
require.True(t, ok)
@ -94,7 +94,7 @@ func TestPutManifestAppendsToExistingManifest(t *testing.T) {
// TestPutManifestTwice tests that existing manifest gets updated and not appended.
func TestPutManifestTwice(t *testing.T) {
ref, tmpDir := refToTempOCI(t)
ref, tmpDir := refToTempOCI(t, false)
ociRef, ok := ref.(ociReference)
require.True(t, ok)
@ -109,7 +109,7 @@ func TestPutManifestTwice(t *testing.T) {
}
func TestPutTwoDifferentTags(t *testing.T) {
ref, tmpDir := refToTempOCI(t)
ref, tmpDir := refToTempOCI(t, false)
ociRef, ok := ref.(ociReference)
require.True(t, ok)

View File

@ -61,22 +61,31 @@ type ociReference struct {
// (But in general, we make no attempt to be completely safe against concurrent hostile filesystem modifications.)
dir string // As specified by the user. May be relative, contain symlinks, etc.
resolvedDir string // Absolute path with no symlinks, at least at the time of its creation. Primarily used for policy namespaces.
// If image=="", it means the "only image" in the index.json is used in the case it is a source
// for destinations, the image name annotation "image.ref.name" is not added to the index.json
// If image=="" && sourceIndex==-1, it means the "only image" in the index.json is used in the case it is a source
// for destinations, the image name annotation "image.ref.name" is not added to the index.json.
//
// Must not be set if sourceIndex is set (the value is not -1).
image string
// If not -1, a zero-based index of an image in the manifest index. Valid only for sources.
// Must not be set if image is set.
sourceIndex int
}
// ParseReference converts a string, which should not start with the ImageTransport.Name prefix, into an OCI ImageReference.
func ParseReference(reference string) (types.ImageReference, error) {
dir, image := internal.SplitPathAndImage(reference)
return NewReference(dir, image)
dir, image, index, err := internal.ParseReferenceIntoElements(reference)
if err != nil {
return nil, err
}
return newReference(dir, image, index)
}
// NewReference returns an OCI reference for a directory and a image.
// newReference returns an OCI reference for a directory, and an image name annotation or sourceIndex.
//
// If sourceIndex==-1, the index will not be valid to point out the source image, only image will be used.
// We do not expose an API supplying the resolvedDir; we could, but recomputing it
// is generally cheap enough that we prefer being confident about the properties of resolvedDir.
func NewReference(dir, image string) (types.ImageReference, error) {
func newReference(dir, image string, sourceIndex int) (types.ImageReference, error) {
resolved, err := explicitfilepath.ResolvePathToFullyExplicit(dir)
if err != nil {
return nil, err
@ -90,7 +99,26 @@ func NewReference(dir, image string) (types.ImageReference, error) {
return nil, err
}
return ociReference{dir: dir, resolvedDir: resolved, image: image}, nil
if sourceIndex != -1 && sourceIndex < 0 {
return nil, fmt.Errorf("Invalid oci: layout reference: index @%d must not be negative", sourceIndex)
}
if sourceIndex != -1 && image != "" {
return nil, fmt.Errorf("Invalid oci: layout reference: cannot use both an image %s and a source index @%d", image, sourceIndex)
}
return ociReference{dir: dir, resolvedDir: resolved, image: image, sourceIndex: sourceIndex}, nil
}
// NewIndexReference returns an OCI reference for a path and a zero-based source manifest index.
func NewIndexReference(dir string, sourceIndex int) (types.ImageReference, error) {
return newReference(dir, "", sourceIndex)
}
// NewReference returns an OCI reference for a directory and a image.
//
// We do not expose an API supplying the resolvedDir; we could, but recomputing it
// is generally cheap enough that we prefer being confident about the properties of resolvedDir.
func NewReference(dir, image string) (types.ImageReference, error) {
return newReference(dir, image, -1)
}
func (ref ociReference) Transport() types.ImageTransport {
@ -103,7 +131,10 @@ func (ref ociReference) Transport() types.ImageTransport {
// e.g. default attribute values omitted by the user may be filled in the return value, or vice versa.
// WARNING: Do not use the return value in the UI to describe an image, it does not contain the Transport().Name() prefix.
func (ref ociReference) StringWithinTransport() string {
return fmt.Sprintf("%s:%s", ref.dir, ref.image)
if ref.sourceIndex == -1 {
return fmt.Sprintf("%s:%s", ref.dir, ref.image)
}
return fmt.Sprintf("%s:@%d", ref.dir, ref.sourceIndex)
}
// DockerReference returns a Docker reference associated with this reference
@ -187,14 +218,18 @@ func (ref ociReference) getManifestDescriptor() (imgspecv1.Descriptor, int, erro
return imgspecv1.Descriptor{}, -1, err
}
if ref.image == "" {
// return manifest if only one image is in the oci directory
if len(index.Manifests) != 1 {
// ask user to choose image when more than one image in the oci directory
return imgspecv1.Descriptor{}, -1, ErrMoreThanOneImage
switch {
case ref.image != "" && ref.sourceIndex != -1:
return imgspecv1.Descriptor{}, -1, fmt.Errorf("Internal error: Cannot have both ref %s and source index @%d",
ref.image, ref.sourceIndex)
case ref.sourceIndex != -1:
if ref.sourceIndex >= len(index.Manifests) {
return imgspecv1.Descriptor{}, -1, fmt.Errorf("index %d is too large, only %d entries available", ref.sourceIndex, len(index.Manifests))
}
return index.Manifests[0], 0, nil
} else {
return index.Manifests[ref.sourceIndex], ref.sourceIndex, nil
case ref.image != "":
// if image specified, look through all manifests for a match
var unsupportedMIMETypes []string
for i, md := range index.Manifests {
@ -208,8 +243,16 @@ func (ref ociReference) getManifestDescriptor() (imgspecv1.Descriptor, int, erro
if len(unsupportedMIMETypes) != 0 {
return imgspecv1.Descriptor{}, -1, fmt.Errorf("reference %q matches unsupported manifest MIME types %q", ref.image, unsupportedMIMETypes)
}
return imgspecv1.Descriptor{}, -1, ImageNotFoundError{ref}
default:
// return manifest if only one image is in the oci directory
if len(index.Manifests) != 1 {
// ask user to choose image when more than one image in the oci directory
return imgspecv1.Descriptor{}, -1, ErrMoreThanOneImage
}
return index.Manifests[0], 0, nil
}
return imgspecv1.Descriptor{}, -1, ImageNotFoundError{ref}
}
// LoadManifestDescriptor loads the manifest descriptor to be used to retrieve the image name

View File

@ -101,6 +101,26 @@ func TestGetManifestDescriptor(t *testing.T) {
}
}
}
ref, err := NewIndexReference("fixtures/two_images_manifest", 0)
assert.NoError(t, err)
res, err := LoadManifestDescriptor(ref)
assert.NoError(t, err)
assert.Equal(t, imgspecv1.Descriptor{
MediaType: "application/vnd.oci.image.manifest.v1+json",
Digest: "sha256:e692418e4cbaf90ca69d05a66403747baa33ee08806650b51fab815ad7fc331f",
Size: 7143,
Platform: &imgspecv1.Platform{
Architecture: "ppc64le",
OS: "linux",
}}, res)
// Out of bounds
ref, err = NewIndexReference("fixtures/two_images_manifest", 6)
assert.NoError(t, err)
_, err = LoadManifestDescriptor(ref)
assert.Error(t, err)
assert.Equal(t, "index 6 is too large, only 2 entries available", err.Error())
}
func TestTransportName(t *testing.T) {
@ -148,11 +168,17 @@ func testParseReference(t *testing.T, fn func(string) (types.ImageReference, err
"relativepath",
tmpDir + "/thisdoesnotexist",
} {
for _, image := range []struct{ suffix, image string }{
{":notlatest:image", "notlatest:image"},
{":latestimage", "latestimage"},
{":", ""},
{"", ""},
for _, image := range []struct {
suffix, image string
sourceIndex int
}{
{":notlatest:image", "notlatest:image", -1},
{":latestimage", "latestimage", -1},
{":", "", -1},
{"", "", -1},
{":@0", "", 0},
{":@10", "", 10},
{":@999999", "", 999999},
} {
input := path + image.suffix
ref, err := fn(input)
@ -161,11 +187,15 @@ func testParseReference(t *testing.T, fn func(string) (types.ImageReference, err
require.True(t, ok)
assert.Equal(t, path, ociRef.dir, input)
assert.Equal(t, image.image, ociRef.image, input)
assert.Equal(t, image.sourceIndex, ociRef.sourceIndex, input)
}
}
_, err := fn(tmpDir + ":invalid'image!value@")
assert.Error(t, err)
_, err = fn(tmpDir + ":@-3")
assert.Error(t, err)
}
func TestNewReference(t *testing.T) {
@ -182,6 +212,7 @@ func TestNewReference(t *testing.T) {
require.True(t, ok)
assert.Equal(t, tmpDir, ociRef.dir)
assert.Equal(t, imageValue, ociRef.image)
assert.Equal(t, -1, ociRef.sourceIndex)
ref, err = NewReference(tmpDir, noImageValue)
require.NoError(t, err)
@ -189,6 +220,7 @@ func TestNewReference(t *testing.T) {
require.True(t, ok)
assert.Equal(t, tmpDir, ociRef.dir)
assert.Equal(t, noImageValue, ociRef.image)
assert.Equal(t, -1, ociRef.sourceIndex)
_, err = NewReference(tmpDir+"/thisparentdoesnotexist/something", imageValue)
assert.Error(t, err)
@ -198,10 +230,50 @@ func TestNewReference(t *testing.T) {
_, err = NewReference(tmpDir+"/has:colon", imageValue)
assert.Error(t, err)
// Test private newReference
_, err = newReference(tmpDir, imageValue, 1)
assert.Error(t, err)
}
func TestNewIndexReference(t *testing.T) {
const imageValue = "imageValue"
tmpDir := t.TempDir()
ref, err := NewIndexReference(tmpDir, 10)
require.NoError(t, err)
ociRef, ok := ref.(ociReference)
require.True(t, ok)
assert.Equal(t, tmpDir, ociRef.dir)
assert.Equal(t, "", ociRef.image)
assert.Equal(t, 10, ociRef.sourceIndex)
ref, err = NewIndexReference(tmpDir, 9999)
require.NoError(t, err)
ociRef, ok = ref.(ociReference)
require.True(t, ok)
assert.Equal(t, tmpDir, ociRef.dir)
assert.Equal(t, "", ociRef.image)
assert.Equal(t, 9999, ociRef.sourceIndex)
_, err = NewIndexReference(tmpDir+"/thisparentdoesnotexist/something", 10)
assert.Error(t, err)
// sourceIndex cannot be less than -1
_, err = NewIndexReference(tmpDir, -3)
assert.Error(t, err)
_, err = NewIndexReference(tmpDir+"/has:colon", 99)
assert.Error(t, err)
// Test private newReference
_, err = newReference(tmpDir, imageValue, 1)
assert.Error(t, err)
}
// refToTempOCI creates a temporary directory and returns an reference to it.
func refToTempOCI(t *testing.T) (types.ImageReference, string) {
func refToTempOCI(t *testing.T, sourceIndex bool) (types.ImageReference, string) {
tmpDir := t.TempDir()
m := `{
"schemaVersion": 2,
@ -221,15 +293,39 @@ func refToTempOCI(t *testing.T) (types.ImageReference, string) {
]
}
`
if sourceIndex {
m = `{
"schemaVersion": 2,
"manifests": [
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"size": 7143,
"digest": "sha256:e692418e4cbaf90ca69d05a66403747baa33ee08806650b51fab815ad7fc331f",
"platform": {
"architecture": "ppc64le",
"os": "linux"
},
}
]
}
`
}
err := os.WriteFile(filepath.Join(tmpDir, "index.json"), []byte(m), 0644)
require.NoError(t, err)
ref, err := NewReference(tmpDir, "imageValue")
require.NoError(t, err)
var ref types.ImageReference
if sourceIndex {
ref, err = NewIndexReference(tmpDir, 1)
require.NoError(t, err)
} else {
ref, err = NewReference(tmpDir, "imageValue")
require.NoError(t, err)
}
return ref, tmpDir
}
func TestReferenceTransport(t *testing.T) {
ref, _ := refToTempOCI(t)
ref, _ := refToTempOCI(t, false)
assert.Equal(t, Transport, ref.Transport())
}
@ -238,7 +334,8 @@ func TestReferenceStringWithinTransport(t *testing.T) {
for _, c := range []struct{ input, result string }{
{"/dir1:notlatest:notlatest", "/dir1:notlatest:notlatest"}, // Explicit image
{"/dir3:", "/dir3:"}, // No image
{"/dir3:", "/dir3:"}, // No image
{"/dir4:@1", "/dir4:@1"}, // Explicit sourceIndex of image
} {
ref, err := ParseReference(tmpDir + c.input)
require.NoError(t, err, c.input)
@ -253,12 +350,12 @@ func TestReferenceStringWithinTransport(t *testing.T) {
}
func TestReferenceDockerReference(t *testing.T) {
ref, _ := refToTempOCI(t)
ref, _ := refToTempOCI(t, false)
assert.Nil(t, ref.DockerReference())
}
func TestReferencePolicyConfigurationIdentity(t *testing.T) {
ref, tmpDir := refToTempOCI(t)
ref, tmpDir := refToTempOCI(t, false)
assert.Equal(t, tmpDir, ref.PolicyConfigurationIdentity())
// A non-canonical path. Test just one, the various other cases are
@ -267,14 +364,27 @@ func TestReferencePolicyConfigurationIdentity(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, tmpDir, ref.PolicyConfigurationIdentity())
// Test the sourceIndex case
ref, tmpDir = refToTempOCI(t, true)
assert.Equal(t, tmpDir, ref.PolicyConfigurationIdentity())
// A non-canonical path. Test just one, the various other cases are
// tested in explicitfilepath.ResolvePathToFullyExplicit.
ref, err = NewIndexReference(tmpDir+"/.", 1)
require.NoError(t, err)
assert.Equal(t, tmpDir, ref.PolicyConfigurationIdentity())
// "/" as a corner case.
ref, err = NewReference("/", "image3")
require.NoError(t, err)
assert.Equal(t, "/", ref.PolicyConfigurationIdentity())
ref, err = NewIndexReference("/", 2)
require.NoError(t, err)
assert.Equal(t, "/", ref.PolicyConfigurationIdentity())
}
func TestReferencePolicyConfigurationNamespaces(t *testing.T) {
ref, tmpDir := refToTempOCI(t)
ref, tmpDir := refToTempOCI(t, false)
// We don't really know enough to make a full equality test here.
ns := ref.PolicyConfigurationNamespaces()
require.NotNil(t, ns)
@ -282,6 +392,15 @@ func TestReferencePolicyConfigurationNamespaces(t *testing.T) {
assert.Equal(t, tmpDir, ns[0])
assert.Equal(t, filepath.Dir(tmpDir), ns[1])
// Test the sourceIndex case
ref, tmpDir = refToTempOCI(t, true)
// We don't really know enough to make a full equality test here.
ns = ref.PolicyConfigurationNamespaces()
require.NotNil(t, ns)
assert.True(t, len(ns) >= 2)
assert.Equal(t, tmpDir, ns[0])
assert.Equal(t, filepath.Dir(tmpDir), ns[1])
// Test with a known path which should exist. Test just one non-canonical
// path, the various other cases are tested in explicitfilepath.ResolvePathToFullyExplicit.
//
@ -302,37 +421,41 @@ func TestReferencePolicyConfigurationNamespaces(t *testing.T) {
ref, err := NewReference("/", "image3")
require.NoError(t, err)
assert.Equal(t, []string{}, ref.PolicyConfigurationNamespaces())
ref, err = NewIndexReference("/", 2)
require.NoError(t, err)
assert.Equal(t, []string{}, ref.PolicyConfigurationNamespaces())
}
func TestReferenceNewImage(t *testing.T) {
ref, _ := refToTempOCI(t)
ref, _ := refToTempOCI(t, false)
_, err := ref.NewImage(context.Background(), nil)
assert.Error(t, err)
}
func TestReferenceNewImageSource(t *testing.T) {
ref, _ := refToTempOCI(t)
ref, _ := refToTempOCI(t, false)
src, err := ref.NewImageSource(context.Background(), nil)
assert.NoError(t, err)
defer src.Close()
}
func TestReferenceNewImageDestination(t *testing.T) {
ref, _ := refToTempOCI(t)
ref, _ := refToTempOCI(t, false)
dest, err := ref.NewImageDestination(context.Background(), nil)
assert.NoError(t, err)
defer dest.Close()
}
func TestReferenceOCILayoutPath(t *testing.T) {
ref, tmpDir := refToTempOCI(t)
ref, tmpDir := refToTempOCI(t, false)
ociRef, ok := ref.(ociReference)
require.True(t, ok)
assert.Equal(t, tmpDir+"/oci-layout", ociRef.ociLayoutPath())
}
func TestReferenceIndexPath(t *testing.T) {
ref, tmpDir := refToTempOCI(t)
ref, tmpDir := refToTempOCI(t, false)
ociRef, ok := ref.(ociReference)
require.True(t, ok)
assert.Equal(t, tmpDir+"/index.json", ociRef.indexPath())
@ -341,7 +464,7 @@ func TestReferenceIndexPath(t *testing.T) {
func TestReferenceBlobPath(t *testing.T) {
const hex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
ref, tmpDir := refToTempOCI(t)
ref, tmpDir := refToTempOCI(t, false)
ociRef, ok := ref.(ociReference)
require.True(t, ok)
bp, err := ociRef.blobPath("sha256:"+hex, "")
@ -352,7 +475,7 @@ func TestReferenceBlobPath(t *testing.T) {
func TestReferenceSharedBlobPathShared(t *testing.T) {
const hex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
ref, _ := refToTempOCI(t)
ref, _ := refToTempOCI(t, false)
ociRef, ok := ref.(ociReference)
require.True(t, ok)
bp, err := ociRef.blobPath("sha256:"+hex, "/external/path")
@ -363,7 +486,7 @@ func TestReferenceSharedBlobPathShared(t *testing.T) {
func TestReferenceBlobPathInvalid(t *testing.T) {
const hex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
ref, _ := refToTempOCI(t)
ref, _ := refToTempOCI(t, false)
ociRef, ok := ref.(ociReference)
require.True(t, ok)
_, err := ociRef.blobPath(hex, "")