Skip to content

Commit

Permalink
mirror: keep order of oci image index manifests and their annotations…
Browse files Browse the repository at this point in the history
… consistent during marshaling

Signed-off-by: Maxim Vasilenko <[email protected]>
  • Loading branch information
mvasl committed Jan 22, 2025
1 parent 6b8a7e6 commit b3c4ede
Show file tree
Hide file tree
Showing 6 changed files with 219 additions and 12 deletions.
8 changes: 8 additions & 0 deletions internal/mirror/cmd/pull/pull.go
Original file line number Diff line number Diff line change
Expand Up @@ -378,5 +378,13 @@ func PullDeckhouseToLocalFS(
}
}

logger.InfoLn("Processing image indexes")
for _, l := range imageLayouts.AllLayouts() {
err = layouts.SortIndexManifests(l)
if err != nil {
return fmt.Errorf("Sorting index manifests of %s: %w", l, err)
}
}

return nil
}
7 changes: 0 additions & 7 deletions pkg/libmirror/images/digests.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,6 @@ func findDigestForInstallerTag(installerTag string, indexManifest *v1.IndexManif
tag := imageManifest.Digest
return &tag
}

// for key, value := range imageManifest.Annotations {
// if key == "org.opencontainers.image.ref.name" && value == installerTag {
// tag := imageManifest.Digest
// return &tag
// }
// }
}
return nil
}
Expand Down
78 changes: 78 additions & 0 deletions pkg/libmirror/layouts/indexes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package layouts

import (
"bytes"
"encoding/json"
"fmt"
"os"
"sort"

v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/layout"
"github.com/google/go-containerregistry/pkg/v1/types"
"golang.org/x/exp/maps"
)

// ociIndexManifest represents an OCI image index.
type ociIndexManifest struct {
SchemaVersion int64 `json:"schemaVersion"`
MediaType types.MediaType `json:"mediaType,omitempty"`
Manifests []v1.Descriptor `json:"manifests"`
Annotations indexManifestAnnotations `json:"annotations,omitempty"`
Subject *v1.Descriptor `json:"subject,omitempty"`
}

// indexManifestAnnotations is a map of image annotations that marshals to JSON form while keeping keys ordered alphabetically.
type indexManifestAnnotations map[string]string

// MarshalJSON marshals go map while keeping keys ordered alphabetically in resulting JSON.
func (a indexManifestAnnotations) MarshalJSON() ([]byte, error) {
names := maps.Keys(a)
sort.Strings(names)

buf := bytes.Buffer{}
buf.WriteRune('{')
for _, key := range names {
buf.WriteRune('"')
buf.WriteString(key)
buf.Write([]byte(`": "`))
buf.WriteString(a[key])
buf.Write([]byte(`",`))
}
js := buf.Bytes()
js[len(js)-1] = '}'
return js, nil
}

func SortIndexManifests(l layout.Path) error {
index, err := l.ImageIndex()
if err != nil {
return fmt.Errorf("Read image index: %w", err)
}

rawManifest, err := index.RawManifest()
if err != nil {
return fmt.Errorf("Read index manifest: %w", err)
}

indexManifest := &ociIndexManifest{}
if err = json.Unmarshal(rawManifest, indexManifest); err != nil {
return fmt.Errorf("Parse index manifest: %w", err)
}

sort.Slice(indexManifest.Manifests, func(i, j int) bool {
ref1 := indexManifest.Manifests[i].Annotations["org.opencontainers.image.ref.name"]
ref2 := indexManifest.Manifests[j].Annotations["org.opencontainers.image.ref.name"]
return ref1 < ref2
})

rawManifest, err = json.MarshalIndent(indexManifest, "", " ")
if err != nil {
return fmt.Errorf("Marshal image index manifest: %w", err)
}
if err = l.WriteFile("index.json", rawManifest, os.ModePerm); err != nil {
return fmt.Errorf("Write image index manifest: %w", err)
}

return nil
}
79 changes: 79 additions & 0 deletions pkg/libmirror/layouts/indexes_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package layouts

import (
"sort"
"testing"

v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/layout"
"github.com/google/go-containerregistry/pkg/v1/random"
"github.com/stretchr/testify/require"
)

func TestSortIndexManifests(t *testing.T) {
const imagesCount = 25
l := createEmptyOCILayout(t)
for i := 0; i < imagesCount; i++ {
img, err := random.Image(512, 4)
require.NoError(t, err, "Images should be generated without problems")

digest, err := img.Digest()
require.NoError(t, err, "Digest should be a resolved")
imageRef := "localhost/repo/image:" + digest.Hex

require.NoError(t, l.AppendImage(
img,
layout.WithPlatform(v1.Platform{Architecture: "amd64", OS: "linux"}),
layout.WithAnnotations(map[string]string{
"org.opencontainers.image.ref.name": imageRef,
"io.deckhouse.image.short_tag": digest.Hex,
})), "Images should be added to layout")
}

err := SortIndexManifests(l)
require.NoError(t, err, "Should be able to sort index manifests without failures")
index, err := l.ImageIndex()
require.NoError(t, err, "Should be able to read index")
indexManifest, err := index.IndexManifest()
require.NoError(t, err, "Should be able to parse index manifest")
require.Len(t, indexManifest.Manifests, imagesCount, "Number of images should not be changed after sorting")
require.True(t, sort.SliceIsSorted(indexManifest.Manifests, func(i, j int) bool {
ref1 := indexManifest.Manifests[i].Annotations["org.opencontainers.image.ref.name"]
ref2 := indexManifest.Manifests[j].Annotations["org.opencontainers.image.ref.name"]
return ref1 < ref2
}), "Index manifests should be sorted by image references")
}

func Test_indexManifestAnnotations_MarshalJSON(t *testing.T) {
tests := []struct {
name string
a indexManifestAnnotations
want []byte
}{
{
name: "one key",
a: indexManifestAnnotations{
"org.opencontainers.image.ref.name": "registry.com/foo:bar",
},
want: []byte(`{"org.opencontainers.image.ref.name": "registry.com/foo:bar"}`),
},
{
name: "multiple keys",
a: indexManifestAnnotations{
"org.opencontainers.image.ref.name": "registry.com/foo:bar",
"short_tag": "bar",
},
want: []byte(`{"org.opencontainers.image.ref.name": "registry.com/foo:bar","short_tag": "bar"}`),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tt.a.MarshalJSON()
require.NoError(t, err)

// JSONEq validates that JSON has valid structure, Equal validates order of fields in JSON.
require.JSONEq(t, string(tt.want), string(got))
require.Equal(t, tt.want, got)
})
}
}
28 changes: 23 additions & 5 deletions pkg/libmirror/layouts/layouts.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ import (
"github.com/deckhouse/deckhouse-cli/pkg/libmirror/util/auth"
)

type ModuleImageLayout struct {
ModuleLayout layout.Path
ModuleImages map[string]struct{}

ReleasesLayout layout.Path
ReleaseImages map[string]struct{}
}

type ImageLayouts struct {
Deckhouse layout.Path
DeckhouseImages map[string]struct{}
Expand Down Expand Up @@ -66,12 +74,22 @@ type ImageLayouts struct {
TagsResolver *TagsResolver
}

type ModuleImageLayout struct {
ModuleLayout layout.Path
ModuleImages map[string]struct{}
func (l *ImageLayouts) AllLayouts() []layout.Path {
paths := []layout.Path{
l.Deckhouse,
l.Install,
l.InstallStandalone,
l.ReleaseChannel,
l.TrivyDB,
l.TrivyBDU,
l.TrivyJavaDB,
l.TrivyChecks,
}
for _, moduleImageLayout := range l.Modules {
paths = append(paths, moduleImageLayout.ModuleLayout, moduleImageLayout.ReleasesLayout)
}

ReleasesLayout layout.Path
ReleaseImages map[string]struct{}
return paths
}

func CreateOCIImageLayoutsForDeckhouse(
Expand Down
31 changes: 31 additions & 0 deletions pkg/libmirror/layouts/layouts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ package layouts
import (
"os"
"path/filepath"
"reflect"
"testing"

"github.com/google/go-containerregistry/pkg/v1/layout"
"github.com/stretchr/testify/require"
)

Expand All @@ -37,3 +39,32 @@ func TestCreateEmptyImageLayoutAtPath(t *testing.T) {
require.FileExists(t, filepath.Join(p, "oci-layout"))
require.FileExists(t, filepath.Join(p, "index.json"))
}

func TestImagesLayoutsAllLayouts(t *testing.T) {
il := &ImageLayouts{
Modules: map[string]ModuleImageLayout{
"commander-agent": {ModuleLayout: createEmptyOCILayout(t), ReleasesLayout: createEmptyOCILayout(t)},
"commander": {ModuleLayout: createEmptyOCILayout(t), ReleasesLayout: createEmptyOCILayout(t)},
},
}

v := reflect.ValueOf(il).Elem()
layoutPathType := reflect.TypeOf(layout.Path(""))
expectedLayouts := make([]layout.Path, 0)
for i := 0; i < v.NumField(); i++ {
if v.Field(i).Type() != layoutPathType {
continue
}

newLayout := string(createEmptyOCILayout(t))
v.Field(i).SetString(newLayout)
expectedLayouts = append(expectedLayouts, layout.Path(v.Field(i).String()))
}
for _, moduleImageLayout := range il.Modules {
expectedLayouts = append(expectedLayouts, moduleImageLayout.ModuleLayout, moduleImageLayout.ReleasesLayout)
}

layouts := il.AllLayouts()
require.Len(t, layouts, len(expectedLayouts), "AllLayouts should return exactly the number of layouts defined within it")
require.ElementsMatch(t, expectedLayouts, layouts, "AllLayouts should return every layout.Path value within it")
}

0 comments on commit b3c4ede

Please sign in to comment.