Skip to content

Commit 2ceebaa

Browse files
authored
Implement crane index subcommand (#1561)
* Implement crane index subcommand crane index filter allows platform-based filtering of manifests in an index. This is primarily useful for folks who want to use only some entries in a base image without having to copy irrelevant platforms. crane index append allows appending manifests to an existing index or creating a new index from scratch. * Reuse v1.Platform parser and Stringer There is some special handling around "all" that I left in place, but at least we can drop some of this code.
1 parent 9cd098e commit 2ceebaa

11 files changed

+572
-32
lines changed

cmd/crane/cmd/index.go

+259
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
// Copyright 2023 Google LLC All Rights Reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package cmd
16+
17+
import (
18+
"errors"
19+
"fmt"
20+
21+
"github.com/google/go-containerregistry/pkg/crane"
22+
"github.com/google/go-containerregistry/pkg/logs"
23+
"github.com/google/go-containerregistry/pkg/name"
24+
v1 "github.com/google/go-containerregistry/pkg/v1"
25+
"github.com/google/go-containerregistry/pkg/v1/empty"
26+
"github.com/google/go-containerregistry/pkg/v1/match"
27+
"github.com/google/go-containerregistry/pkg/v1/mutate"
28+
"github.com/google/go-containerregistry/pkg/v1/partial"
29+
"github.com/google/go-containerregistry/pkg/v1/remote"
30+
"github.com/google/go-containerregistry/pkg/v1/types"
31+
"github.com/spf13/cobra"
32+
)
33+
34+
// NewCmdIndex creates a new cobra.Command for the index subcommand.
35+
func NewCmdIndex(options *[]crane.Option) *cobra.Command {
36+
cmd := &cobra.Command{
37+
Use: "index",
38+
Short: "Modify an image index.",
39+
Args: cobra.ExactArgs(1),
40+
Run: func(cmd *cobra.Command, _ []string) {
41+
cmd.Usage()
42+
},
43+
}
44+
cmd.AddCommand(NewCmdIndexFilter(options), NewCmdIndexAppend(options))
45+
return cmd
46+
}
47+
48+
// NewCmdIndexFilter creates a new cobra.Command for the index filter subcommand.
49+
func NewCmdIndexFilter(options *[]crane.Option) *cobra.Command {
50+
var newTag string
51+
platforms := &platformsValue{}
52+
53+
cmd := &cobra.Command{
54+
Use: "filter",
55+
Short: "Modifies a remote index by filtering based on platform.",
56+
Example: ` # Filter out weird platforms from ubuntu, copy result to example.com/ubuntu
57+
crane index filter ubuntu --platform linux/amd64 --platform linux/arm64 -t example.com/ubuntu
58+
59+
# Filter out any non-linux platforms, push to example.com/hello-world
60+
crane index filter hello-world --platform linux -t example.com/hello-world
61+
62+
# Same as above, but in-place
63+
crane index filter example.com/hello-world:some-tag --platform linux`,
64+
Args: cobra.ExactArgs(1),
65+
RunE: func(_ *cobra.Command, args []string) error {
66+
o := crane.GetOptions(*options...)
67+
baseRef := args[0]
68+
69+
ref, err := name.ParseReference(baseRef)
70+
if err != nil {
71+
return err
72+
}
73+
base, err := remote.Index(ref, o.Remote...)
74+
if err != nil {
75+
return fmt.Errorf("pulling %s: %w", baseRef, err)
76+
}
77+
78+
idx := filterIndex(base, platforms.platforms)
79+
80+
digest, err := idx.Digest()
81+
if err != nil {
82+
return err
83+
}
84+
85+
if newTag != "" {
86+
ref, err = name.ParseReference(newTag)
87+
if err != nil {
88+
return fmt.Errorf("parsing reference %s: %w", newTag, err)
89+
}
90+
} else {
91+
if _, ok := ref.(name.Digest); ok {
92+
ref = ref.Context().Digest(digest.String())
93+
}
94+
}
95+
96+
if err := remote.WriteIndex(ref, idx, o.Remote...); err != nil {
97+
return fmt.Errorf("pushing image %s: %w", newTag, err)
98+
}
99+
fmt.Println(ref.Context().Digest(digest.String()))
100+
return nil
101+
},
102+
}
103+
cmd.Flags().StringVarP(&newTag, "tag", "t", "", "Tag to apply to resulting image")
104+
105+
// Consider reusing the persistent flag for this, it's separate so we can have multiple values.
106+
cmd.Flags().Var(platforms, "platform", "Specifies the platform(s) to keep from base in the form os/arch[/variant][:osversion][,<platform>] (e.g. linux/amd64).")
107+
108+
return cmd
109+
}
110+
111+
// NewCmdIndexAppend creates a new cobra.Command for the index append subcommand.
112+
func NewCmdIndexAppend(options *[]crane.Option) *cobra.Command {
113+
var baseRef, newTag string
114+
var newManifests []string
115+
var dockerEmptyBase bool
116+
117+
cmd := &cobra.Command{
118+
Use: "append",
119+
Short: "Append manifests to a remote index.",
120+
Long: `This sub-command pushes an index based on an (optional) base index, with appended manifests.
121+
122+
The platform for appended manifests is inferred from the config file or omitted if that is infeasible.`,
123+
Example: ` # Append a windows hello-world image to ubuntu, push to example.com/hello-world:weird
124+
crane index append ubuntu -m hello-world@sha256:87b9ca29151260634b95efb84d43b05335dc3ed36cc132e2b920dd1955342d20 -t example.com/hello-world:weird
125+
126+
# Create an index from scratch for etcd.
127+
crane index append -m registry.k8s.io/etcd-amd64:3.4.9 -m registry.k8s.io/etcd-arm64:3.4.9 -t example.com/etcd`,
128+
Args: cobra.MaximumNArgs(1),
129+
RunE: func(_ *cobra.Command, args []string) error {
130+
if len(args) == 1 {
131+
baseRef = args[0]
132+
}
133+
o := crane.GetOptions(*options...)
134+
135+
var (
136+
base v1.ImageIndex
137+
err error
138+
ref name.Reference
139+
)
140+
141+
if baseRef == "" {
142+
if newTag == "" {
143+
return errors.New("at least one of --base or --tag must be specified")
144+
}
145+
146+
logs.Warn.Printf("base unspecified, using empty index")
147+
base = empty.Index
148+
if dockerEmptyBase {
149+
base = mutate.IndexMediaType(base, types.DockerManifestList)
150+
}
151+
} else {
152+
ref, err = name.ParseReference(baseRef)
153+
if err != nil {
154+
return err
155+
}
156+
base, err = remote.Index(ref, o.Remote...)
157+
if err != nil {
158+
return fmt.Errorf("pulling %s: %w", baseRef, err)
159+
}
160+
}
161+
162+
adds := make([]mutate.IndexAddendum, 0, len(newManifests))
163+
164+
for _, m := range newManifests {
165+
ref, err := name.ParseReference(m)
166+
if err != nil {
167+
return err
168+
}
169+
desc, err := remote.Get(ref, o.Remote...)
170+
if err != nil {
171+
return err
172+
}
173+
if desc.MediaType.IsImage() {
174+
img, err := desc.Image()
175+
if err != nil {
176+
return err
177+
}
178+
179+
cf, err := img.ConfigFile()
180+
if err != nil {
181+
return err
182+
}
183+
newDesc, err := partial.Descriptor(img)
184+
if err != nil {
185+
return err
186+
}
187+
newDesc.Platform = cf.Platform()
188+
adds = append(adds, mutate.IndexAddendum{
189+
Add: img,
190+
Descriptor: *newDesc,
191+
})
192+
} else if desc.MediaType.IsIndex() {
193+
idx, err := desc.ImageIndex()
194+
if err != nil {
195+
return err
196+
}
197+
adds = append(adds, mutate.IndexAddendum{
198+
Add: idx,
199+
})
200+
} else {
201+
return fmt.Errorf("saw unexpected MediaType %q for %q", desc.MediaType, m)
202+
}
203+
}
204+
205+
idx := mutate.AppendManifests(base, adds...)
206+
digest, err := idx.Digest()
207+
if err != nil {
208+
return err
209+
}
210+
211+
if newTag != "" {
212+
ref, err = name.ParseReference(newTag)
213+
if err != nil {
214+
return fmt.Errorf("parsing reference %s: %w", newTag, err)
215+
}
216+
} else {
217+
if _, ok := ref.(name.Digest); ok {
218+
ref = ref.Context().Digest(digest.String())
219+
}
220+
}
221+
222+
if err := remote.WriteIndex(ref, idx, o.Remote...); err != nil {
223+
return fmt.Errorf("pushing image %s: %w", newTag, err)
224+
}
225+
fmt.Println(ref.Context().Digest(digest.String()))
226+
return nil
227+
},
228+
}
229+
cmd.Flags().StringVarP(&newTag, "tag", "t", "", "Tag to apply to resulting image")
230+
cmd.Flags().StringSliceVarP(&newManifests, "manifest", "m", []string{}, "References to manifests to append to the base index")
231+
cmd.Flags().BoolVar(&dockerEmptyBase, "docker-empty-base", false, "If true, empty base index will have Docker media types instead of OCI")
232+
233+
return cmd
234+
}
235+
236+
func filterIndex(idx v1.ImageIndex, platforms []v1.Platform) v1.ImageIndex {
237+
matcher := not(satisfiesPlatforms(platforms))
238+
return mutate.RemoveManifests(idx, matcher)
239+
}
240+
241+
func satisfiesPlatforms(platforms []v1.Platform) match.Matcher {
242+
return func(desc v1.Descriptor) bool {
243+
if desc.Platform == nil {
244+
return false
245+
}
246+
for _, p := range platforms {
247+
if desc.Platform.Satisfies(p) {
248+
return true
249+
}
250+
}
251+
return false
252+
}
253+
}
254+
255+
func not(in match.Matcher) match.Matcher {
256+
return func(desc v1.Descriptor) bool {
257+
return !in(desc)
258+
}
259+
}

cmd/crane/cmd/root.go

+1
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ func New(use, short string, options []crane.Option) *cobra.Command {
106106
cmd.NewCmdEdit(&options),
107107
NewCmdExport(&options),
108108
NewCmdFlatten(&options),
109+
NewCmdIndex(&options),
109110
NewCmdList(&options),
110111
NewCmdManifest(&options),
111112
NewCmdMutate(&options),

cmd/crane/cmd/util.go

+31-32
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,40 @@
1515
package cmd
1616

1717
import (
18-
"fmt"
1918
"strings"
2019

2120
v1 "github.com/google/go-containerregistry/pkg/v1"
2221
)
2322

23+
type platformsValue struct {
24+
platforms []v1.Platform
25+
}
26+
27+
func (ps *platformsValue) Set(platform string) error {
28+
if ps.platforms == nil {
29+
ps.platforms = []v1.Platform{}
30+
}
31+
p, err := parsePlatform(platform)
32+
if err != nil {
33+
return err
34+
}
35+
pv := platformValue{p}
36+
ps.platforms = append(ps.platforms, *pv.platform)
37+
return nil
38+
}
39+
40+
func (ps *platformsValue) String() string {
41+
ss := make([]string, 0, len(ps.platforms))
42+
for _, p := range ps.platforms {
43+
ss = append(ss, p.String())
44+
}
45+
return strings.Join(ss, ",")
46+
}
47+
48+
func (ps *platformsValue) Type() string {
49+
return "platform(s)"
50+
}
51+
2452
type platformValue struct {
2553
platform *v1.Platform
2654
}
@@ -46,42 +74,13 @@ func platformToString(p *v1.Platform) string {
4674
if p == nil {
4775
return "all"
4876
}
49-
platform := ""
50-
if p.OS != "" && p.Architecture != "" {
51-
platform = p.OS + "/" + p.Architecture
52-
}
53-
if p.Variant != "" {
54-
platform += "/" + p.Variant
55-
}
56-
return platform
77+
return p.String()
5778
}
5879

5980
func parsePlatform(platform string) (*v1.Platform, error) {
6081
if platform == "all" {
6182
return nil, nil
6283
}
6384

64-
p := &v1.Platform{}
65-
66-
parts := strings.SplitN(platform, ":", 2)
67-
if len(parts) == 2 {
68-
p.OSVersion = parts[1]
69-
}
70-
71-
parts = strings.Split(parts[0], "/")
72-
73-
if len(parts) < 2 {
74-
return nil, fmt.Errorf("failed to parse platform '%s': expected format os/arch[/variant]", platform)
75-
}
76-
if len(parts) > 3 {
77-
return nil, fmt.Errorf("failed to parse platform '%s': too many slashes", platform)
78-
}
79-
80-
p.OS = parts[0]
81-
p.Architecture = parts[1]
82-
if len(parts) > 2 {
83-
p.Variant = parts[2]
84-
}
85-
86-
return p, nil
85+
return v1.ParsePlatform(platform)
8786
}

cmd/crane/doc/crane.md

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cmd/crane/doc/crane_index.md

+29
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)