Skip to content

Commit 2c321ce

Browse files
committed
[MCO-1956] Create and test image inspection utils
Add logic and tests that can be used to inspect and work with images. This code can be reused in multiple components, like the build controller or the future OSImageStream logic.
1 parent e712da2 commit 2c321ce

File tree

4 files changed

+344
-35
lines changed

4 files changed

+344
-35
lines changed

pkg/controller/build/imagepruner/imageinspect.go

Lines changed: 14 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,10 @@ package imagepruner
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67

78
"github.com/containers/common/pkg/retry"
8-
"github.com/containers/image/v5/image"
9-
"github.com/containers/image/v5/manifest"
109
"github.com/containers/image/v5/types"
1110
digest "github.com/opencontainers/go-digest"
1211
"github.com/openshift/machine-config-operator/pkg/imageutils"
@@ -85,48 +84,28 @@ func deleteImage(ctx context.Context, sysCtx *types.SystemContext, imageName str
8584
// TODO(jkyros): Revisit direct skopeo inspect usage, but direct library calls are beneficial for error context.
8685
//
8786
//nolint:unparam
88-
func imageInspect(ctx context.Context, sysCtx *types.SystemContext, imageName string) (*types.ImageInspectInfo, *digest.Digest, error) {
89-
ref, err := imageutils.ParseImageName(imageName)
90-
if err != nil {
91-
return nil, nil, fmt.Errorf("error parsing image name %q: %w", imageName, err)
92-
}
93-
87+
func imageInspect(ctx context.Context, sysCtx *types.SystemContext, imageName string) (inspectInfo *types.ImageInspectInfo, digest *digest.Digest, err error) {
9488
retryOpts := retry.RetryOptions{
9589
MaxRetry: cmdRetriesCount,
9690
}
97-
src, err := imageutils.GetImageSourceFromReference(ctx, sysCtx, ref, &retryOpts)
91+
image, imgSource, err := imageutils.GetImage(ctx, sysCtx, imageName, &retryOpts)
9892
if err != nil {
99-
return nil, nil, fmt.Errorf("error getting image source for %s: %w", imageName, err)
93+
return nil, nil, newErrImage(imageName, fmt.Errorf("error fetching underlying image: %w", err))
10094
}
101-
defer src.Close()
95+
defer func() {
96+
if imgSourceErr := imgSource.Close(); imgSourceErr != nil {
97+
err = errors.Join(err, imgSourceErr)
98+
}
99+
}()
102100

103-
var rawManifest []byte
104-
unparsedInstance := image.UnparsedInstance(src, nil)
105-
if err := retry.IfNecessary(ctx, func() error {
106-
rawManifest, _, err = unparsedInstance.Manifest(ctx)
107-
return err
108-
}, &retryOpts); err != nil {
109-
return nil, nil, fmt.Errorf("error retrieving manifest for image: %w", err)
110-
}
111-
112-
// get the digest here because it's not part of the image inspection
113-
digest, err := manifest.Digest(rawManifest)
101+
inspectInfo, err = imageutils.GetInspectInfoFromImage(ctx, image, &retryOpts)
114102
if err != nil {
115-
return nil, nil, fmt.Errorf("error retrieving image digest: %q: %w", imageName, err)
103+
return nil, nil, newErrImage(imageName, fmt.Errorf("error inspecting image: %w", err))
116104
}
117105

118-
img, err := image.FromUnparsedImage(ctx, sysCtx, unparsedInstance)
106+
imageDigest, err := imageutils.GetDigestFromImage(ctx, image, &retryOpts)
119107
if err != nil {
120-
return nil, nil, newErrImage(imageName, fmt.Errorf("error parsing manifest for image: %w", err))
108+
return nil, nil, newErrImage(imageName, fmt.Errorf("error fetching image digest: %w", err))
121109
}
122-
123-
var imgInspect *types.ImageInspectInfo
124-
if err := retry.IfNecessary(ctx, func() error {
125-
imgInspect, err = img.Inspect(ctx)
126-
return err
127-
}, &retryOpts); err != nil {
128-
return nil, nil, newErrImage(imageName, err)
129-
}
130-
131-
return imgInspect, &digest, nil
110+
return inspectInfo, &imageDigest, nil
132111
}

pkg/imageutils/image_inspect.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package imageutils
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
8+
"github.com/containers/common/pkg/retry"
9+
"github.com/containers/image/v5/image"
10+
"github.com/containers/image/v5/manifest"
11+
"github.com/containers/image/v5/types"
12+
"github.com/opencontainers/go-digest"
13+
)
14+
15+
// GetImage retrieves a container image by name using the provided system context.
16+
// The returned ImageSource must be closed by the caller.
17+
func GetImage(ctx context.Context, sysCtx *types.SystemContext, imageName string, retryOpts *retry.RetryOptions) (types.Image, types.ImageSource, error) {
18+
ref, err := ParseImageName(imageName)
19+
if err != nil {
20+
return nil, nil, fmt.Errorf("error parsing image name %q: %w", imageName, err)
21+
}
22+
23+
source, err := GetImageSourceFromReference(ctx, sysCtx, ref, retryOpts)
24+
if err != nil {
25+
return nil, nil, fmt.Errorf("error getting image source for %s: %w", imageName, err)
26+
}
27+
28+
img, err := image.FromUnparsedImage(ctx, sysCtx, image.UnparsedInstance(source, nil))
29+
if err != nil {
30+
return nil, nil, errors.Join(err, source.Close())
31+
}
32+
33+
return img, source, nil
34+
}
35+
36+
// GetDigestFromImage computes and returns the manifest digest for the given image.
37+
// It includes retry logic to handle transient network errors.
38+
func GetDigestFromImage(ctx context.Context, image types.Image, retryOpts *retry.RetryOptions) (digest.Digest, error) {
39+
var (
40+
rawManifest []byte
41+
err error
42+
)
43+
if err = retry.IfNecessary(ctx, func() error {
44+
rawManifest, _, err = image.Manifest(ctx)
45+
return err
46+
}, retryOpts); err != nil {
47+
return "", fmt.Errorf("error retrieving manifest for image: %w", err)
48+
}
49+
return manifest.Digest(rawManifest)
50+
}
51+
52+
// GetInspectInfoFromImage retrieves detailed inspection information for the given image.
53+
// It includes retry logic to handle transient network errors.
54+
func GetInspectInfoFromImage(ctx context.Context, image types.Image, retryOpts *retry.RetryOptions) (*types.ImageInspectInfo, error) {
55+
var (
56+
imgInspect *types.ImageInspectInfo
57+
err error
58+
)
59+
return imgInspect, retry.IfNecessary(ctx, func() error {
60+
imgInspect, err = image.Inspect(ctx)
61+
return err
62+
}, retryOpts)
63+
}

pkg/imageutils/layer_reader.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package imageutils
2+
3+
import (
4+
"archive/tar"
5+
"compress/gzip"
6+
"context"
7+
"errors"
8+
"io"
9+
"slices"
10+
11+
"github.com/containers/common/pkg/retry"
12+
"github.com/containers/image/v5/pkg/blobinfocache/none"
13+
"github.com/containers/image/v5/types"
14+
)
15+
16+
// ReadImageFileContentFn is a predicate function that returns true when
17+
// the tar header matches the desired file to extract from the image layer.
18+
type ReadImageFileContentFn func(*tar.Header) bool
19+
20+
// ReadImageFileContent searches for and extracts a file from a container image.
21+
// It iterates through the image layers (starting from the last) and uses matcherFn
22+
// to identify the target file. When found, the file content is returned as a byte slice.
23+
// If no matching file is found, (nil, nil) is returned.
24+
func ReadImageFileContent(ctx context.Context, sysCtx *types.SystemContext, imageName string, matcherFn ReadImageFileContentFn) (content []byte, err error) {
25+
ref, err := ParseImageName(imageName)
26+
if err != nil {
27+
return nil, err
28+
}
29+
img, err := ref.NewImage(ctx, sysCtx)
30+
if err != nil {
31+
return nil, err
32+
}
33+
defer func() {
34+
if closeErr := img.Close(); closeErr != nil {
35+
err = errors.Join(err, closeErr)
36+
}
37+
}()
38+
39+
src, err := GetImageSourceFromReference(ctx, sysCtx, ref, &retry.Options{MaxRetry: 2})
40+
if err != nil {
41+
return nil, err
42+
}
43+
defer func() {
44+
if closeErr := src.Close(); closeErr != nil {
45+
err = errors.Join(err, closeErr)
46+
}
47+
}()
48+
49+
layerInfos := img.LayerInfos()
50+
51+
// Small optimization: Usually user defined content is
52+
// at the very end layers so start searching backwards
53+
// may result in finding the file sooner.
54+
slices.Reverse(layerInfos)
55+
for _, info := range layerInfos {
56+
if content, err = searchLayerForFile(ctx, src, info, matcherFn); err != nil || content != nil {
57+
return content, err
58+
}
59+
}
60+
return nil, nil
61+
62+
}
63+
64+
func searchLayerForFile(ctx context.Context, imgSrc types.ImageSource, blobInfo types.BlobInfo, matcherFn ReadImageFileContentFn) (content []byte, err error) {
65+
layerStream, _, err := imgSrc.GetBlob(ctx, blobInfo, none.NoCache)
66+
if err != nil {
67+
return nil, err
68+
}
69+
defer func() {
70+
if closeErr := layerStream.Close(); closeErr != nil {
71+
err = errors.Join(err, closeErr)
72+
}
73+
}()
74+
75+
// The layer content is just a gzip tar file. Create both readers
76+
gzr, err := gzip.NewReader(layerStream)
77+
if err != nil {
78+
return nil, err
79+
}
80+
defer func() {
81+
if closeErr := gzr.Close(); closeErr != nil {
82+
err = errors.Join(err, closeErr)
83+
}
84+
}()
85+
86+
// Open the tar and search for the target file till
87+
// we find it or no more files are present in the tar
88+
tr := tar.NewReader(gzr)
89+
for {
90+
var header *tar.Header
91+
header, err = tr.Next()
92+
if err == io.EOF {
93+
break
94+
}
95+
if err != nil {
96+
return nil, err
97+
}
98+
if matcherFn(header) {
99+
content, err = io.ReadAll(tr)
100+
return content, err
101+
}
102+
}
103+
104+
// The target file wasn't found
105+
return nil, nil
106+
}

0 commit comments

Comments
 (0)