Skip to content

Commit 41ee952

Browse files
Merge pull request #5409 from pablintino/stream-image-utils
MCO-1956: Stream image utils
2 parents 5de3906 + a5812ee commit 41ee952

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{}, min(i.count, imagesCount))
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)