Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

imagetools: Allow annotations for OCI image index #1965

Merged
merged 2 commits into from
Aug 8, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -1101,7 +1101,7 @@ func BuildWithResultHandler(ctx context.Context, nodes []builder.Node, opt map[s
}
}

dt, desc, err := itpull.Combine(ctx, srcs)
dt, desc, err := itpull.Combine(ctx, srcs, nil)
if err != nil {
return err
}
Expand Down
21 changes: 20 additions & 1 deletion commands/imagetools/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ type createOptions struct {
builder string
files []string
tags []string
annotations []string
dryrun bool
actionAppend bool
progress string
Expand Down Expand Up @@ -82,6 +83,11 @@ func runCreate(dockerCli command.Cli, in createOptions, args []string) error {
return errors.Errorf("no repositories specified, please set a reference in tag or source")
}

ann, err := parseAnnotations(in.annotations)
if err != nil {
return err
}

var defaultRepo *string
if len(repos) == 1 {
for repo := range repos {
Expand Down Expand Up @@ -154,7 +160,7 @@ func runCreate(dockerCli command.Cli, in createOptions, args []string) error {
}
}

dt, desc, err := r.Combine(ctx, srcs)
dt, desc, err := r.Combine(ctx, srcs, ann)
if err != nil {
return err
}
Expand Down Expand Up @@ -264,6 +270,18 @@ func parseSource(in string) (*imagetools.Source, error) {
return &s, nil
}

func parseAnnotations(in []string) (map[string]string, error) {
out := make(map[string]string)
for _, i := range in {
kv := strings.SplitN(i, "=", 2)
if len(kv) != 2 {
return nil, errors.Errorf("invalid annotation %q, expected key=value", in)
}
out[kv[0]] = kv[1]
}
return out, nil
}

func createCmd(dockerCli command.Cli, opts RootOptions) *cobra.Command {
var options createOptions

Expand All @@ -283,6 +301,7 @@ func createCmd(dockerCli command.Cli, opts RootOptions) *cobra.Command {
flags.BoolVar(&options.dryrun, "dry-run", false, "Show final image instead of pushing")
flags.BoolVar(&options.actionAppend, "append", false, "Append to existing manifest")
flags.StringVar(&options.progress, "progress", "auto", `Set type of progress output ("auto", "plain", "tty"). Use plain to show container output`)
flags.StringArrayVarP(&options.annotations, "annotation", "", []string{}, "Add annotation to the image")

return cmd
}
Expand Down
1 change: 1 addition & 0 deletions docs/reference/buildx_imagetools_create.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Create a new image based on source images

| Name | Type | Default | Description |
|:---------------------------------|:--------------|:--------|:-----------------------------------------------------------------------------------------|
| `--annotation` | `stringArray` | | Add annotation to the image |
| [`--append`](#append) | | | Append to existing manifest |
| [`--builder`](#builder) | `string` | | Override the configured builder instance |
| [`--dry-run`](#dry-run) | | | Show final image instead of pushing |
Expand Down
101 changes: 89 additions & 12 deletions tests/imagetools.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package tests

import (
"encoding/json"
"os/exec"
"testing"

"github.com/containerd/containerd/platforms"
Expand All @@ -14,25 +15,15 @@ import (

var imagetoolsTests = []func(t *testing.T, sb integration.Sandbox){
testImagetoolsInspectAndFilter,
testImagetoolsAnnotation,
}

func testImagetoolsInspectAndFilter(t *testing.T, sb integration.Sandbox) {
if sb.Name() != "docker-container" {
t.Skip("imagetools tests are not driver specific and only run on docker-container")
}

dockerfile := []byte(`
FROM scratch
ARG TARGETARCH
COPY foo-${TARGETARCH} /foo
`)
dir := tmpdir(
t,
fstest.CreateFile("Dockerfile", dockerfile, 0600),
fstest.CreateFile("foo-amd64", []byte("foo-amd64"), 0600),
fstest.CreateFile("foo-arm64", []byte("foo-arm64"), 0600),
)

dir := createDockerfile(t)
jedevc marked this conversation as resolved.
Show resolved Hide resolved
registry, err := sb.NewRegistry()
if errors.Is(err, integration.ErrRequirements) {
t.Skip(err.Error())
Expand Down Expand Up @@ -77,3 +68,89 @@ func testImagetoolsInspectAndFilter(t *testing.T, sb integration.Sandbox) {
require.Equal(t, idx.Manifests[1].Digest, idx2.Manifests[0].Digest)
require.Equal(t, platforms.Format(*idx.Manifests[1].Platform), platforms.Format(*idx2.Manifests[0].Platform))
}

func testImagetoolsAnnotation(t *testing.T, sb integration.Sandbox) {
if sb.Name() != "docker-container" {
t.Skip("imagetools tests are not driver specific and only run on docker-container")
}

dir := createDockerfile(t)
registry, err := sb.NewRegistry()
if errors.Is(err, integration.ErrRequirements) {
t.Skip(err.Error())
}
require.NoError(t, err)
target := registry + "/buildx/imtools:latest"

out, err := buildCmd(sb, withArgs("--output", "type=registry,oci-mediatypes=true,name="+target, "--platform=linux/amd64,linux/arm64", "--provenance=false", dir))
require.NoError(t, err, string(out))

cmd := buildxCmd(sb, withArgs("imagetools", "inspect", target, "--raw"))
dt, err := cmd.CombinedOutput()
require.NoError(t, err, string(dt))

var idx ocispecs.Index
err = json.Unmarshal(dt, &idx)
require.NoError(t, err)
require.Empty(t, idx.Annotations)

imagetoolsCmd := func(source []string) *exec.Cmd {
args := []string{"imagetools", "create", "-t", target, "--annotation", "index:foo=bar", "--annotation", "index:bar=baz",
"--annotation", "manifest-descriptor:foo=bar", "--annotation", "manifest-descriptor[linux/amd64]:bar=baz"}
args = append(args, source...)
return buildxCmd(sb, withArgs(args...))
}
sources := [][]string{
{
target,
},
{
target + "@" + string(idx.Manifests[0].Digest),
target + "@" + string(idx.Manifests[1].Digest),
},
}
for _, source := range sources {
cmd = imagetoolsCmd(source)
dt, err = cmd.CombinedOutput()
require.NoError(t, err, string(dt))

newTarget := registry + "/buildx/imtools:annotations"
cmd = buildxCmd(sb, withArgs("imagetools", "create", "-t", newTarget, target))
dt, err = cmd.CombinedOutput()
require.NoError(t, err, string(dt))

cmd = buildxCmd(sb, withArgs("imagetools", "inspect", newTarget, "--raw"))
dt, err = cmd.CombinedOutput()
require.NoError(t, err, string(dt))

err = json.Unmarshal(dt, &idx)
require.NoError(t, err)
require.Len(t, idx.Annotations, 2)
require.Equal(t, "bar", idx.Annotations["foo"])
require.Equal(t, "baz", idx.Annotations["bar"])
require.Len(t, idx.Manifests, 2)
for _, mfst := range idx.Manifests {
require.Equal(t, "bar", mfst.Annotations["foo"])
if platforms.Format(*mfst.Platform) == "linux/amd64" {
require.Equal(t, "baz", mfst.Annotations["bar"])
} else {
require.Empty(t, mfst.Annotations["bar"])
}
}
}
}

func createDockerfile(t *testing.T) string {
dockerfile := []byte(`
FROM scratch
ARG TARGETARCH
COPY foo-${TARGETARCH} /foo
`)
dir := tmpdir(
t,
fstest.CreateFile("Dockerfile", dockerfile, 0600),
fstest.CreateFile("foo-amd64", []byte("foo-amd64"), 0600),
fstest.CreateFile("foo-arm64", []byte("foo-arm64"), 0600),
)
return dir
}
84 changes: 81 additions & 3 deletions util/imagetools/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"net/url"
"regexp"
"strings"

"github.com/containerd/containerd/content"
Expand All @@ -13,6 +14,7 @@ import (
"github.com/containerd/containerd/platforms"
"github.com/containerd/containerd/remotes"
"github.com/docker/distribution/reference"
"github.com/moby/buildkit/exporter/containerimage/exptypes"
"github.com/moby/buildkit/util/contentutil"
"github.com/opencontainers/go-digest"
"github.com/opencontainers/image-spec/specs-go"
Expand All @@ -26,7 +28,7 @@ type Source struct {
Ref reference.Named
}

func (r *Resolver) Combine(ctx context.Context, srcs []*Source) ([]byte, ocispec.Descriptor, error) {
func (r *Resolver) Combine(ctx context.Context, srcs []*Source, ann map[string]string) ([]byte, ocispec.Descriptor, error) {
eg, ctx := errgroup.WithContext(ctx)

dts := make([][]byte, len(srcs))
Expand Down Expand Up @@ -75,7 +77,7 @@ func (r *Resolver) Combine(ctx context.Context, srcs []*Source) ([]byte, ocispec
}

// on single source, return original bytes
if len(srcs) == 1 {
if len(srcs) == 1 && len(ann) == 0 {
if mt := srcs[0].Desc.MediaType; mt == images.MediaTypeDockerSchema2ManifestList || mt == ocispec.MediaTypeImageIndex {
return dts[0], srcs[0].Desc, nil
}
Expand Down Expand Up @@ -138,12 +140,39 @@ func (r *Resolver) Combine(ctx context.Context, srcs []*Source) ([]byte, ocispec
mt = ocispec.MediaTypeImageIndex
}

// annotations are only allowed on OCI indexes
indexAnnotation := make(map[string]string)
if mt == ocispec.MediaTypeImageIndex {
annotations, err := parseAnnotations(ann)
if err != nil {
return nil, ocispec.Descriptor{}, err
}
if len(annotations[exptypes.AnnotationIndex]) > 0 {
for k, v := range annotations[exptypes.AnnotationIndex] {
indexAnnotation[k.Key] = v
}
}
if len(annotations[exptypes.AnnotationManifestDescriptor]) > 0 {
for i := 0; i < len(newDescs); i++ {
if newDescs[i].Annotations == nil {
newDescs[i].Annotations = map[string]string{}
}
for k, v := range annotations[exptypes.AnnotationManifestDescriptor] {
if k.Platform == nil || k.PlatformString() == platforms.Format(*newDescs[i].Platform) {
newDescs[i].Annotations[k.Key] = v
}
}
}
}
}

idxBytes, err := json.MarshalIndent(ocispec.Index{
MediaType: mt,
Versioned: specs.Versioned{
SchemaVersion: 2,
},
Manifests: newDescs,
Manifests: newDescs,
Annotations: indexAnnotation,
}, "", " ")
if err != nil {
return nil, ocispec.Descriptor{}, errors.Wrap(err, "failed to marshal index")
Expand Down Expand Up @@ -266,3 +295,52 @@ func detectMediaType(dt []byte) (string, error) {

return images.MediaTypeDockerSchema2ManifestList, nil
}

func parseAnnotations(ann map[string]string) (map[string]map[exptypes.AnnotationKey]string, error) {
// TODO: use buildkit's annotation parser once it supports setting custom prefix and ":" separator
annotationRegexp := regexp.MustCompile(`^([a-z-]+)(?:\[([A-Za-z0-9_/-]+)\])?:(\S+)$`)
indexAnnotations := make(map[exptypes.AnnotationKey]string)
manifestDescriptorAnnotations := make(map[exptypes.AnnotationKey]string)
for k, v := range ann {
groups := annotationRegexp.FindStringSubmatch(k)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of using a regexp here, wdyt about using https://github.com/moby/buildkit/blob/dd0053cdce470b1355fdb0bd5a8f2b0fc506d842/exporter/containerimage/exptypes/annotations.go#L85 instead?

Obviously, it's not perfect, since we'd need to add the annotation- prefix here, which might make the error message a bit odd, but we could always upstream a buildkit fix later that would rework the function to have the caller remove the prefix there (so we wouldn't have to do that).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of using a regexp here, wdyt about using https://github.com/moby/buildkit/blob/dd0053cdce470b1355fdb0bd5a8f2b0fc506d842/exporter/containerimage/exptypes/annotations.go#L85 instead?

Initially I had the same idea but we are using : as a separator for type and annotation key compared to . expected here. So it needs more work than just appending annotation- prefix.

but we could always upstream a buildkit fix later that would rework the function

Does it make sense to make buildkit parser to also handle : separator. It sounded specific to buildx so I feel we should keep it here. wdyt?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally, I'd prefer the conversion to buildkit's form for now, so that we only have one source of truth for parsing these keys - I'm happy to follow up in buildkit to rework the logic to be a bit more reusable for this case.

I also just noticed (sorry), that we aren't handling platforms? manifest-descriptor supports a platform, and should only be attached to the descriptor for those platforms.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm happy to follow up in buildkit to rework the logic to be a bit more reusable for this case.

Agreed. That should be the way moving forward. I added a comment regrading using buildkit once it supports our use-case here.

I also just noticed (sorry), that we aren't handling platforms?

Great Catch. Done.

if groups == nil {
return nil, errors.Errorf("invalid annotation format, expected <type>:<key>=<value>, got %q", k)
}

typ, platform, key := groups[1], groups[2], groups[3]
var ociPlatform *ocispec.Platform
if platform != "" {
p, err := platforms.Parse(platform)
if err != nil {
return nil, errors.Wrapf(err, "invalid platform %q", platform)
}
ociPlatform = &p
}
switch typ {
case exptypes.AnnotationIndex:
ak := exptypes.AnnotationKey{
Type: typ,
Platform: ociPlatform,
Key: key,
}
indexAnnotations[ak] = v
case exptypes.AnnotationManifestDescriptor:
ak := exptypes.AnnotationKey{
Type: typ,
Platform: ociPlatform,
Key: key,
}
manifestDescriptorAnnotations[ak] = v
case exptypes.AnnotationManifest:
return nil, errors.Errorf("%q annotations are not supported yet", typ)
case exptypes.AnnotationIndexDescriptor:
return nil, errors.Errorf("%q annotations are invalid while creating an image", typ)
default:
return nil, errors.Errorf("unknown annotation type %q", typ)
}
}
return map[string]map[exptypes.AnnotationKey]string{
exptypes.AnnotationIndex: indexAnnotations,
exptypes.AnnotationManifestDescriptor: manifestDescriptorAnnotations,
}, nil
}