Skip to content

Commit 98dbd31

Browse files
authored
Merge pull request crossplane#6262 from haarchri/feature/crank-xpextract
feat(crank): add xpkg extract command
2 parents 8945066 + e907e82 commit 98dbd31

File tree

5 files changed

+422
-1
lines changed

5 files changed

+422
-1
lines changed

cmd/crank/xpkg/extract.go

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
/*
2+
Copyright 2025 The Crossplane Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package xpkg
18+
19+
import (
20+
"archive/tar"
21+
"compress/gzip"
22+
"context"
23+
"io"
24+
"os"
25+
"path/filepath"
26+
"time"
27+
28+
"github.com/google/go-containerregistry/pkg/name"
29+
v1 "github.com/google/go-containerregistry/pkg/v1"
30+
"github.com/google/go-containerregistry/pkg/v1/daemon"
31+
"github.com/google/go-containerregistry/pkg/v1/mutate"
32+
"github.com/google/go-containerregistry/pkg/v1/remote"
33+
"github.com/google/go-containerregistry/pkg/v1/tarball"
34+
"github.com/spf13/afero"
35+
36+
"github.com/crossplane/crossplane-runtime/pkg/errors"
37+
"github.com/crossplane/crossplane-runtime/pkg/logging"
38+
39+
"github.com/crossplane/crossplane/internal/xpkg"
40+
"github.com/crossplane/crossplane/internal/xpkg/upbound"
41+
)
42+
43+
const (
44+
errMustProvideTag = "must provide package tag if fetching from registry or daemon"
45+
errInvalidTag = "package tag is not a valid reference"
46+
errFetchPackage = "failed to fetch package from remote"
47+
errGetManifest = "failed to get package image manifest from remote"
48+
errFetchLayer = "failed to fetch annotated base layer from remote"
49+
errGetUncompressed = "failed to get uncompressed contents from layer"
50+
errMultipleAnnotatedLayers = "package is invalid due to multiple annotated base layers"
51+
errOpenPackageStream = "failed to open package stream file"
52+
errCreateOutputFile = "failed to create output file"
53+
errCreateGzipWriter = "failed to create gzip writer"
54+
errExtractPackageContents = "failed to extract package contents"
55+
cacheContentExt = ".gz"
56+
)
57+
58+
// fetchFn fetches a package from a source.
59+
type fetchFn func(context.Context, name.Reference) (v1.Image, error)
60+
61+
// registryFetch fetches a package from the registry.
62+
func registryFetch(ctx context.Context, r name.Reference) (v1.Image, error) {
63+
return remote.Image(r, remote.WithContext(ctx))
64+
}
65+
66+
// daemonFetch fetches a package from the Docker daemon.
67+
func daemonFetch(ctx context.Context, r name.Reference) (v1.Image, error) {
68+
return daemon.Image(r, daemon.WithContext(ctx))
69+
}
70+
71+
func xpkgFetch(path string) fetchFn {
72+
return func(_ context.Context, _ name.Reference) (v1.Image, error) {
73+
return tarball.ImageFromPath(filepath.Clean(path), nil)
74+
}
75+
}
76+
77+
// AfterApply constructs and binds context to any subcommands
78+
// that have Run() methods that receive it.
79+
func (c *extractCmd) AfterApply() error {
80+
c.fs = afero.NewOsFs()
81+
c.fetch = registryFetch
82+
if c.FromDaemon {
83+
c.fetch = daemonFetch
84+
}
85+
if c.FromXpkg {
86+
// If package is not defined, attempt to find single package in current
87+
// directory.
88+
if c.Package == "" {
89+
wd, err := os.Getwd()
90+
if err != nil {
91+
return errors.Wrap(err, errGetwd)
92+
}
93+
path, err := xpkg.FindXpkgInDir(c.fs, wd)
94+
if err != nil {
95+
return errors.Wrap(err, errFindPackageinWd)
96+
}
97+
c.Package = path
98+
}
99+
c.fetch = xpkgFetch(c.Package)
100+
}
101+
if !c.FromXpkg {
102+
if c.Package == "" {
103+
return errors.New(errMustProvideTag)
104+
}
105+
upCtx, err := upbound.NewFromFlags(c.Flags)
106+
if err != nil {
107+
return err
108+
}
109+
110+
name, err := name.ParseReference(c.Package, name.WithDefaultRegistry(upCtx.RegistryEndpoint.Hostname()))
111+
if err != nil {
112+
return errors.Wrap(err, errInvalidTag)
113+
}
114+
c.name = name
115+
}
116+
return nil
117+
}
118+
119+
// extractCmd extracts package contents into a Crossplane cache compatible
120+
// format.
121+
type extractCmd struct {
122+
fs afero.Fs
123+
name name.Reference
124+
fetch fetchFn
125+
126+
Package string `arg:"" help:"Name of the package to extract. Must be a valid OCI image tag or a path if using --from-xpkg." optional:""`
127+
FromDaemon bool `help:"Indicates that the image should be fetched from the Docker daemon."`
128+
FromXpkg bool `help:"Indicates that the image should be fetched from a local xpkg. If package is not specified and only one exists in current directory it will be used."`
129+
Output string `default:"out.gz" help:"Package output file path. Extension must be .gz or will be replaced." short:"o"`
130+
131+
// Common API configuration
132+
Flags upbound.Flags `embed:""`
133+
}
134+
135+
// Run runs the xpkg extract cmd.
136+
func (c *extractCmd) Run(logger logging.Logger) error { //nolint:gocyclo // xpkg extract for cli
137+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
138+
defer cancel()
139+
140+
// Fetch package.
141+
img, err := c.fetch(ctx, c.name)
142+
if err != nil {
143+
return errors.Wrap(err, errFetchPackage)
144+
}
145+
146+
// Get image manifest.
147+
manifest, err := img.Manifest()
148+
if err != nil {
149+
return errors.Wrap(err, errGetManifest)
150+
}
151+
152+
// Determine if the image is using annotated layers.
153+
var tarc io.ReadCloser
154+
foundAnnotated := false
155+
for _, l := range manifest.Layers {
156+
if a, ok := l.Annotations[xpkg.AnnotationKey]; !ok || a != xpkg.PackageAnnotation {
157+
continue
158+
}
159+
if foundAnnotated {
160+
return errors.New(errMultipleAnnotatedLayers)
161+
}
162+
foundAnnotated = true
163+
layer, err := img.LayerByDigest(l.Digest)
164+
if err != nil {
165+
return errors.Wrap(err, errFetchLayer)
166+
}
167+
tarc, err = layer.Uncompressed()
168+
if err != nil {
169+
return errors.Wrap(err, errGetUncompressed)
170+
}
171+
}
172+
173+
// If we still don't have content then we need to flatten image filesystem.
174+
if !foundAnnotated {
175+
tarc = mutate.Extract(img)
176+
}
177+
178+
// The ReadCloser is an uncompressed tarball, either consisting of annotated
179+
// layer contents or flattened filesystem content. Either way, we only want
180+
// the package YAML stream.
181+
t := tar.NewReader(tarc)
182+
var size int64
183+
for {
184+
h, err := t.Next()
185+
if err != nil {
186+
return errors.Wrap(err, errOpenPackageStream)
187+
}
188+
if h.Name == xpkg.StreamFile {
189+
size = h.Size
190+
break
191+
}
192+
}
193+
194+
out := xpkg.ReplaceExt(filepath.Clean(c.Output), cacheContentExt)
195+
cf, err := c.fs.Create(out)
196+
if err != nil {
197+
return errors.Wrap(err, errCreateOutputFile)
198+
}
199+
defer cf.Close() //nolint:errcheck // defer close
200+
w, err := gzip.NewWriterLevel(cf, gzip.BestSpeed)
201+
if err != nil {
202+
return errors.Wrap(err, errCreateGzipWriter)
203+
}
204+
if _, err = io.CopyN(w, t, size); err != nil {
205+
return errors.Wrap(err, errExtractPackageContents)
206+
}
207+
if err := w.Close(); err != nil {
208+
return errors.Wrap(err, errExtractPackageContents)
209+
}
210+
211+
logger.Debug("xpkg contents extracted to %s", out)
212+
return nil
213+
}

cmd/crank/xpkg/extract_test.go

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
/*
2+
Copyright 2025 The Crossplane Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package xpkg
18+
19+
import (
20+
"archive/tar"
21+
"bytes"
22+
"context"
23+
"io"
24+
"strings"
25+
"testing"
26+
27+
"github.com/google/go-cmp/cmp"
28+
"github.com/google/go-containerregistry/pkg/name"
29+
v1 "github.com/google/go-containerregistry/pkg/v1"
30+
"github.com/google/go-containerregistry/pkg/v1/empty"
31+
"github.com/google/go-containerregistry/pkg/v1/mutate"
32+
"github.com/google/go-containerregistry/pkg/v1/random"
33+
"github.com/google/go-containerregistry/pkg/v1/tarball"
34+
"github.com/google/go-containerregistry/pkg/v1/types"
35+
"github.com/spf13/afero"
36+
37+
"github.com/crossplane/crossplane-runtime/pkg/errors"
38+
"github.com/crossplane/crossplane-runtime/pkg/logging"
39+
"github.com/crossplane/crossplane-runtime/pkg/test"
40+
41+
"github.com/crossplane/crossplane/internal/xpkg"
42+
)
43+
44+
func TestExtractRun(t *testing.T) {
45+
errBoom := errors.New("boom")
46+
validTag := name.MustParseReference("crossplane/provider-aws:v0.24.1")
47+
randLayer, _ := random.Layer(int64(1000), types.DockerLayer)
48+
randImg, _ := mutate.Append(empty.Image, mutate.Addendum{
49+
Layer: randLayer,
50+
Annotations: map[string]string{
51+
xpkg.AnnotationKey: xpkg.PackageAnnotation,
52+
},
53+
})
54+
55+
randImgDup, _ := mutate.Append(randImg, mutate.Addendum{
56+
Layer: randLayer,
57+
Annotations: map[string]string{
58+
xpkg.AnnotationKey: xpkg.PackageAnnotation,
59+
},
60+
})
61+
62+
streamCont := "somestreamofyaml"
63+
tarBuf := new(bytes.Buffer)
64+
tw := tar.NewWriter(tarBuf)
65+
hdr := &tar.Header{
66+
Name: xpkg.StreamFile,
67+
Mode: int64(xpkg.StreamFileMode),
68+
Size: int64(len(streamCont)),
69+
}
70+
_ = tw.WriteHeader(hdr)
71+
_, _ = io.Copy(tw, strings.NewReader(streamCont))
72+
_ = tw.Close()
73+
74+
packLayer, _ := tarball.LayerFromOpener(func() (io.ReadCloser, error) {
75+
// NOTE(hasheddan): we must construct a new reader each time as we
76+
// ingest packImg in multiple tests below.
77+
return io.NopCloser(bytes.NewReader(tarBuf.Bytes())), nil
78+
})
79+
packImg, _ := mutate.AppendLayers(empty.Image, packLayer)
80+
cases := map[string]struct {
81+
reason string
82+
fs afero.Fs
83+
name name.Reference
84+
fetch fetchFn
85+
out string
86+
want error
87+
}{
88+
"ErrorFetchPackage": {
89+
reason: "Should return error if we fail to fetch package.",
90+
name: validTag,
91+
fetch: func(_ context.Context, _ name.Reference) (v1.Image, error) {
92+
return nil, errBoom
93+
},
94+
want: errors.Wrap(errBoom, errFetchPackage),
95+
},
96+
"ErrorMultipleAnnotatedLayers": {
97+
reason: "Should return error if manifest contains multiple annotated layers.",
98+
name: validTag,
99+
fetch: func(_ context.Context, _ name.Reference) (v1.Image, error) {
100+
return randImgDup, nil
101+
},
102+
want: errors.New(errMultipleAnnotatedLayers),
103+
},
104+
"ErrorFetchBadPackage": {
105+
reason: "Should return error if image with contents does not have package.yaml.",
106+
name: validTag,
107+
fetch: func(_ context.Context, _ name.Reference) (v1.Image, error) {
108+
return randImg, nil
109+
},
110+
want: errors.Wrap(io.EOF, errOpenPackageStream),
111+
},
112+
"Success": {
113+
reason: "Should not return error if we successfully fetch package and extract contents.",
114+
name: validTag,
115+
fetch: func(_ context.Context, _ name.Reference) (v1.Image, error) {
116+
return packImg, nil
117+
},
118+
fs: afero.NewMemMapFs(),
119+
out: "out.gz",
120+
},
121+
}
122+
for name, tc := range cases {
123+
t.Run(name, func(t *testing.T) {
124+
logger := logging.NewNopLogger()
125+
126+
err := (&extractCmd{
127+
fs: tc.fs,
128+
fetch: tc.fetch,
129+
name: tc.name,
130+
Output: tc.out,
131+
}).Run(logger)
132+
if diff := cmp.Diff(tc.want, err, test.EquateErrors()); diff != "" {
133+
t.Errorf("\n%s\nRun(...): -want error, +got error:\n%s", tc.reason, diff)
134+
}
135+
})
136+
}
137+
}

cmd/crank/xpkg/xpkg.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,14 @@ type Cmd struct {
2929
Logout logoutCmd `cmd:"" help:"Logout of the default package registry."`
3030
Push pushCmd `cmd:"" help:"Push a package to a registry."`
3131
Update updateCmd `cmd:"" help:"Update a package in a control plane."`
32+
Extract extractCmd `cmd:"" help:"Extract package contents into a Crossplane cache compatible format. Fetches from a remote registry by default."`
3233
}
3334

3435
// Help prints out the help for the xpkg command.
3536
func (c *Cmd) Help() string {
3637
return `
3738
Crossplane can be extended using packages. Crossplane packages are called xpkgs.
38-
Crossplane supports configuration, provider and function packages.
39+
Crossplane supports configuration, provider and function packages.
3940
4041
A package is an opinionated OCI image that contains everything needed to extend
4142
a Crossplane control plane with new functionality. For example installing a

internal/xpkg/name.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,3 +144,9 @@ func parseNameFromPackage(bs []byte) (string, error) {
144144
err := yaml.Unmarshal(bs, p)
145145
return p.Metadata.Name, err
146146
}
147+
148+
// ReplaceExt replaces the file extension of the given path.
149+
func ReplaceExt(path, ext string) string {
150+
old := filepath.Ext(path)
151+
return path[0:len(path)-len(old)] + ext
152+
}

0 commit comments

Comments
 (0)