Skip to content

Commit 8c00de2

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 8c00de2

File tree

4 files changed

+581
-35
lines changed

4 files changed

+581
-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: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
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+
}
64+
65+
// BulkInspectResult represents the result of inspecting a single image in a bulk operation.
66+
// It contains either the inspection information or an error if the inspection failed.
67+
type BulkInspectResult struct {
68+
Image string
69+
InspectInfo *types.ImageInspectInfo
70+
Error error
71+
}
72+
73+
// BulkInspectorOptions configures the behavior of a BulkInspector.
74+
// RetryOpts specifies retry behavior for transient network errors.
75+
// FailOnErr determines whether to return immediately on the first error (true)
76+
// or to continue inspecting all images and collect all results (false).
77+
// Count limits the number of concurrent image inspections. If Count is 0 or
78+
// negative, no limit is applied and all images are inspected concurrently.
79+
type BulkInspectorOptions struct {
80+
RetryOpts *retry.RetryOptions
81+
FailOnErr bool
82+
Count int
83+
}
84+
85+
// BulkInspector performs concurrent image inspections with optional rate limiting
86+
// and configurable error handling.
87+
type BulkInspector struct {
88+
retryOpts *retry.RetryOptions
89+
failOnErr bool
90+
count int
91+
}
92+
93+
// NewBulkInspector creates a new BulkInspector with the provided options.
94+
// If opts is nil or RetryOpts is nil, sensible defaults are applied:
95+
// - RetryOpts.MaxRetry defaults to 2
96+
// - FailOnErr defaults to false
97+
// - Count defaults to 0 (unlimited concurrency)
98+
func NewBulkInspector(opts *BulkInspectorOptions) *BulkInspector {
99+
if opts == nil {
100+
opts = &BulkInspectorOptions{}
101+
}
102+
if opts.RetryOpts == nil {
103+
opts.RetryOpts = &retry.RetryOptions{MaxRetry: 2}
104+
}
105+
return &BulkInspector{
106+
retryOpts: opts.RetryOpts,
107+
failOnErr: opts.FailOnErr,
108+
count: opts.Count,
109+
}
110+
}
111+
112+
// Inspect concurrently inspects multiple container images and returns their inspection results.
113+
// The inspection is performed with optional rate limiting based on the Count configuration.
114+
// If FailOnErr is true, the method returns immediately upon encountering the first error.
115+
// Otherwise, it inspects all images and returns results for all of them, with errors
116+
// recorded in individual BulkInspectResult entries.
117+
// Results are returned in completion order, not input order. Use the Image field to
118+
// correlate results with the input image names.
119+
func (i *BulkInspector) Inspect(ctx context.Context, sysCtx *types.SystemContext, images ...string) ([]BulkInspectResult, error) {
120+
imagesCount := len(images)
121+
if imagesCount == 0 {
122+
return []BulkInspectResult{}, nil
123+
}
124+
125+
results := make(chan BulkInspectResult, imagesCount)
126+
var rateLimiterChannel chan struct{}
127+
if i.count > 0 {
128+
rateLimiterChannel = make(chan struct{}, i.count)
129+
} else {
130+
// No rate limiting - use a channel that never blocks
131+
rateLimiterChannel = make(chan struct{}, imagesCount)
132+
}
133+
134+
childContext, cancel := context.WithCancel(ctx)
135+
// The deferred cancellation of the context will kill the tasks when exiting
136+
// Useful in case of error
137+
defer cancel()
138+
for _, imageName := range images {
139+
go func(img string) {
140+
select {
141+
case rateLimiterChannel <- struct{}{}:
142+
defer func() { <-rateLimiterChannel }()
143+
144+
inspectInfo, err := i.inspectImage(childContext, sysCtx, img)
145+
results <- BulkInspectResult{Image: img, InspectInfo: inspectInfo, Error: err}
146+
case <-childContext.Done():
147+
results <- BulkInspectResult{Error: childContext.Err(), Image: img, InspectInfo: nil}
148+
}
149+
}(imageName)
150+
}
151+
152+
inspectInfos := make([]BulkInspectResult, imagesCount)
153+
for idx := range imagesCount {
154+
res := <-results
155+
if res.Error != nil && i.failOnErr {
156+
return nil, res.Error
157+
}
158+
inspectInfos[idx] = res
159+
}
160+
return inspectInfos, nil
161+
}
162+
163+
func (i *BulkInspector) inspectImage(ctx context.Context, sysCtx *types.SystemContext, image string) (inspectInfo *types.ImageInspectInfo, err error) {
164+
img, imgSource, err := GetImage(ctx, sysCtx, image, i.retryOpts)
165+
if err != nil {
166+
return nil, err
167+
}
168+
defer func() {
169+
if imgSourceErr := imgSource.Close(); imgSourceErr != nil {
170+
err = errors.Join(err, imgSourceErr)
171+
}
172+
}()
173+
return GetInspectInfoFromImage(ctx, img, i.retryOpts)
174+
}

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)