mirror of
https://github.com/regclient/regclient.git
synced 2025-04-18 22:44:00 +03:00
- Use "any" instead of an empty interface. - Use range over an integer for for loops. - Remove shadow variables in loops now that Go no longer reuses the variable. - Use "slices.Contains", "slices.Delete", "slices.Equal", "slices.Index", "slices.SortFunc". - Use "cmp.Or", "min", and "max". - Use "fmt.Appendf" instead of "Sprintf" for generating a byte slice. - Use "errors.Join" or "fmt.Errorf" with multiple "%w" for multiple errors. Additionally, use modern regclient features: - Use "ref.SetTag", "ref.SetDigest", and "ref.AddDigest". - Call "regclient.ManifestGet" using "WithManifestDesc" instead of setting the digest on the reference. Signed-off-by: Brandon Mitchell <git@bmitch.net>
390 lines
11 KiB
Go
390 lines
11 KiB
Go
// Package mod changes an image according to the requested modifications.
|
|
package mod
|
|
|
|
import (
|
|
"archive/tar"
|
|
"compress/gzip"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"slices"
|
|
"time"
|
|
|
|
"github.com/klauspost/compress/zstd"
|
|
"github.com/opencontainers/go-digest"
|
|
|
|
"github.com/regclient/regclient"
|
|
"github.com/regclient/regclient/pkg/archive"
|
|
"github.com/regclient/regclient/types/descriptor"
|
|
"github.com/regclient/regclient/types/mediatype"
|
|
"github.com/regclient/regclient/types/ref"
|
|
"github.com/regclient/regclient/types/warning"
|
|
)
|
|
|
|
// Opts defines options for Apply
|
|
type Opts func(*dagConfig, *dagManifest) error
|
|
|
|
// OptTime defines time settings for [WithConfigTimestamp] and [WithLayerTimestamp].
|
|
type OptTime struct {
|
|
Set time.Time // time to set, this or FromLabel are required
|
|
FromLabel string // label from which to extract set time
|
|
After time.Time // only change times that are after this
|
|
BaseRef ref.Ref // define base image, do not alter timestamps from base layers
|
|
BaseLayers int // define a number of layers to not modify (count of the layers in a base image)
|
|
}
|
|
|
|
var (
|
|
// known tar media types
|
|
mtKnownTar = []string{
|
|
mediatype.Docker2Layer,
|
|
mediatype.Docker2LayerGzip,
|
|
mediatype.Docker2LayerZstd,
|
|
mediatype.OCI1Layer,
|
|
mediatype.OCI1LayerGzip,
|
|
mediatype.OCI1LayerZstd,
|
|
}
|
|
// known config media types
|
|
mtKnownConfig = []string{
|
|
mediatype.Docker2ImageConfig,
|
|
mediatype.OCI1ImageConfig,
|
|
}
|
|
)
|
|
|
|
// Apply applies a set of modifications to an image (manifest, configs, and layers).
|
|
func Apply(ctx context.Context, rc *regclient.RegClient, rSrc ref.Ref, opts ...Opts) (ref.Ref, error) {
|
|
// dedup warnings
|
|
if w := warning.FromContext(ctx); w == nil {
|
|
ctx = warning.NewContext(ctx, &warning.Warning{Hook: warning.DefaultHook()})
|
|
}
|
|
|
|
// pull the image metadata into a DAG
|
|
dm, err := dagGet(ctx, rc, rSrc, descriptor.Descriptor{})
|
|
if err != nil {
|
|
return rSrc, err
|
|
}
|
|
dm.top = true
|
|
|
|
// load the options
|
|
rTgt := rSrc.SetTag("")
|
|
dc := dagConfig{
|
|
stepsManifest: []func(context.Context, *regclient.RegClient, ref.Ref, ref.Ref, *dagManifest) error{},
|
|
stepsOCIConfig: []func(context.Context, *regclient.RegClient, ref.Ref, ref.Ref, *dagOCIConfig) error{},
|
|
stepsLayer: []func(context.Context, *regclient.RegClient, ref.Ref, ref.Ref, *dagLayer, io.ReadCloser) (io.ReadCloser, error){},
|
|
stepsLayerFile: []func(context.Context, *regclient.RegClient, ref.Ref, ref.Ref, *dagLayer, *tar.Header, io.Reader) (*tar.Header, io.Reader, changes, error){},
|
|
maxDataSize: -1, // unchanged, if a data field exists, preserve it
|
|
rTgt: rTgt,
|
|
}
|
|
for _, opt := range opts {
|
|
if err := opt(&dc, dm); err != nil {
|
|
return rSrc, err
|
|
}
|
|
}
|
|
rTgt = dc.rTgt
|
|
|
|
// perform manifest changes
|
|
if len(dc.stepsManifest) > 0 {
|
|
err = dagWalkManifests(dm, func(dm *dagManifest) (*dagManifest, error) {
|
|
for _, fn := range dc.stepsManifest {
|
|
err := fn(ctx, rc, rSrc, rTgt, dm)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return dm, nil
|
|
})
|
|
if err != nil {
|
|
return rTgt, err
|
|
}
|
|
}
|
|
// perform config changes
|
|
if len(dc.stepsOCIConfig) > 0 {
|
|
err = dagWalkOCIConfig(dm, func(doc *dagOCIConfig) (*dagOCIConfig, error) {
|
|
for _, fn := range dc.stepsOCIConfig {
|
|
err := fn(ctx, rc, rSrc, rTgt, doc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return doc, nil
|
|
})
|
|
if err != nil {
|
|
return rTgt, err
|
|
}
|
|
}
|
|
// perform layer changes and copy layers to target repository
|
|
if len(dc.stepsLayer) > 0 || len(dc.stepsLayerFile) > 0 || !ref.EqualRepository(rSrc, rTgt) || dc.forceLayerWalk {
|
|
err = dagWalkLayers(dm, func(dl *dagLayer) (*dagLayer, error) {
|
|
var rdr io.ReadCloser
|
|
defer func() {
|
|
if rdr != nil {
|
|
_ = rdr.Close()
|
|
}
|
|
}()
|
|
var err error
|
|
rSrc := rSrc
|
|
if dl.rSrc.IsSet() {
|
|
rSrc = dl.rSrc
|
|
}
|
|
if dl.mod == deleted || len(dl.desc.URLs) > 0 {
|
|
// skip deleted and external layers
|
|
return dl, nil
|
|
}
|
|
// changes for the entire layer
|
|
if len(dc.stepsLayer) > 0 {
|
|
bRdr, err := rc.BlobGet(ctx, rSrc, dl.desc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rdr = bRdr
|
|
for _, sl := range dc.stepsLayer {
|
|
rdrNext, err := sl(ctx, rc, rSrc, rTgt, dl, rdr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rdr = rdrNext
|
|
}
|
|
}
|
|
// changes for files within layers require extracting the tar and then repackaging it
|
|
if len(dc.stepsLayerFile) > 0 && slices.Contains(mtKnownTar, dl.desc.MediaType) {
|
|
if dl.mod == deleted {
|
|
return dl, nil
|
|
}
|
|
if rdr == nil {
|
|
bRdr, err := rc.BlobGet(ctx, rSrc, dl.desc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rdr = bRdr
|
|
}
|
|
changed := false
|
|
empty := true
|
|
desc := dl.desc
|
|
if dl.newDesc.MediaType != "" {
|
|
desc = dl.newDesc
|
|
}
|
|
// if compressed, setup a decompressing reader that passes through the close
|
|
if desc.MediaType != mediatype.OCI1Layer && desc.MediaType != mediatype.Docker2Layer {
|
|
dr, err := archive.Decompress(rdr)
|
|
if err != nil {
|
|
_ = rdr.Close()
|
|
return nil, err
|
|
}
|
|
rdr = readCloserFn{Reader: dr, closeFn: rdr.Close}
|
|
}
|
|
// setup tar reader to process layer
|
|
tr := tar.NewReader(rdr)
|
|
// create temp file and setup tar writer
|
|
fh, err := os.CreateTemp("", "regclient-mod-")
|
|
if err != nil {
|
|
_ = rdr.Close()
|
|
return nil, err
|
|
}
|
|
defer func() {
|
|
_ = fh.Close()
|
|
_ = os.Remove(fh.Name())
|
|
}()
|
|
var tw *tar.Writer
|
|
var gw *gzip.Writer
|
|
var zw *zstd.Encoder
|
|
digRaw := desc.DigestAlgo().Digester() // raw/compressed digest
|
|
digUC := desc.DigestAlgo().Digester() // uncompressed digest
|
|
if dl.desc.MediaType == mediatype.Docker2LayerGzip || dl.desc.MediaType == mediatype.OCI1LayerGzip {
|
|
cw := io.MultiWriter(fh, digRaw.Hash())
|
|
gw = gzip.NewWriter(cw)
|
|
defer gw.Close()
|
|
ucw := io.MultiWriter(gw, digUC.Hash())
|
|
tw = tar.NewWriter(ucw)
|
|
} else if dl.desc.MediaType == mediatype.Docker2LayerZstd || dl.desc.MediaType == mediatype.OCI1LayerZstd {
|
|
cw := io.MultiWriter(fh, digRaw.Hash())
|
|
zw, err = zstd.NewWriter(cw)
|
|
if err != nil {
|
|
_ = rdr.Close()
|
|
return nil, err
|
|
}
|
|
defer zw.Close()
|
|
ucw := io.MultiWriter(zw, digUC.Hash())
|
|
tw = tar.NewWriter(ucw)
|
|
} else {
|
|
dw := io.MultiWriter(fh, digRaw.Hash(), digUC.Hash())
|
|
tw = tar.NewWriter(dw)
|
|
}
|
|
// iterate over files in the layer
|
|
for {
|
|
th, err := tr.Next()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
changeFile := unchanged
|
|
var fileRdr io.Reader
|
|
fileRdr = tr
|
|
for _, slf := range dc.stepsLayerFile {
|
|
var changeCur changes
|
|
th, fileRdr, changeCur, err = slf(ctx, rc, rSrc, rTgt, dl, th, fileRdr)
|
|
if err != nil {
|
|
_ = rdr.Close()
|
|
return nil, err
|
|
}
|
|
if changeCur != unchanged {
|
|
changed = true
|
|
}
|
|
if changeCur == deleted {
|
|
changeFile = deleted
|
|
break
|
|
}
|
|
}
|
|
// copy th and tr to temp tar writer file
|
|
if changeFile != deleted {
|
|
empty = false
|
|
err = tw.WriteHeader(th)
|
|
if err != nil {
|
|
_ = rdr.Close()
|
|
return nil, err
|
|
}
|
|
if th.Typeflag == tar.TypeReg && th.Size > 0 {
|
|
_, err := io.CopyN(tw, fileRdr, th.Size)
|
|
if err != nil {
|
|
_ = rdr.Close()
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if empty {
|
|
dl.mod = deleted
|
|
return dl, nil
|
|
}
|
|
if changed {
|
|
// close to flush remaining content
|
|
err = tw.Close()
|
|
if err != nil {
|
|
_ = rdr.Close()
|
|
return nil, fmt.Errorf("failed to close temporary tar layer: %w", err)
|
|
}
|
|
if gw != nil {
|
|
err = gw.Close()
|
|
if err != nil {
|
|
_ = rdr.Close()
|
|
return nil, fmt.Errorf("failed to close gzip writer: %w", err)
|
|
}
|
|
}
|
|
if zw != nil {
|
|
err = zw.Close()
|
|
if err != nil {
|
|
_ = rdr.Close()
|
|
return nil, fmt.Errorf("failed to close zstd writer: %w", err)
|
|
}
|
|
}
|
|
err = rdr.Close()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to close layer reader: %w", err)
|
|
}
|
|
// replace the current reader and save the digests
|
|
l, err := fh.Seek(0, 1)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
_, err = fh.Seek(0, 0)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rdr = fh
|
|
desc.Digest = digRaw.Digest()
|
|
desc.Size = l
|
|
dl.newDesc = desc
|
|
dl.ucDigest = digUC.Digest()
|
|
if dl.mod == unchanged {
|
|
dl.mod = replaced
|
|
}
|
|
}
|
|
}
|
|
// if added or replaced, and reader not nil, push blob
|
|
if (dl.mod == added || dl.mod == replaced) && rdr != nil {
|
|
// push the blob and verify the results
|
|
dNew, err := rc.BlobPut(ctx, rTgt, dl.newDesc, rdr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
err = rdr.Close()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if dl.newDesc.Digest == "" {
|
|
dl.newDesc.Digest = dNew.Digest
|
|
} else if dl.newDesc.Digest != dNew.Digest {
|
|
return nil, fmt.Errorf("layer digest mismatch, pushed %s, expected %s", dNew.Digest.String(), dl.newDesc.Digest.String())
|
|
}
|
|
if dl.newDesc.Size == 0 {
|
|
dl.newDesc.Size = dNew.Size
|
|
} else if dl.newDesc.Size != dNew.Size {
|
|
return nil, fmt.Errorf("layer size mismatch, pushed %d, expected %d", dNew.Size, dl.newDesc.Size)
|
|
}
|
|
}
|
|
// for unchanged layers, if the repository is different, copy the blob
|
|
if dl.mod == unchanged && !ref.EqualRepository(rSrc, rTgt) {
|
|
err = rc.BlobCopy(ctx, rSrc, rTgt, dl.desc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return dl, nil
|
|
})
|
|
if err != nil {
|
|
return rTgt, err
|
|
}
|
|
}
|
|
|
|
// push the resulting changed content, both manifests and configs
|
|
err = dagPut(ctx, rc, dc, rSrc, rTgt, dm)
|
|
if err != nil {
|
|
return rTgt, err
|
|
}
|
|
if rTgt.Tag == "" || rTgt.Digest != "" {
|
|
rTgt = rTgt.AddDigest(dm.m.GetDescriptor().Digest.String())
|
|
}
|
|
return rTgt, nil
|
|
}
|
|
|
|
// WithRefTgt sets the target manifest.
|
|
// Apply will default to pushing to the same name by digest.
|
|
func WithRefTgt(rTgt ref.Ref) Opts {
|
|
return func(dc *dagConfig, dm *dagManifest) error {
|
|
dc.rTgt = rTgt
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// WithData sets the descriptor data field max size.
|
|
// This also strips the data field off descriptors above the max size.
|
|
func WithData(maxDataSize int64) Opts {
|
|
return func(dc *dagConfig, dm *dagManifest) error {
|
|
dc.maxDataSize = maxDataSize
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// WithDigestAlgo sets the digest algorithm for both manifests and layers.
|
|
func WithDigestAlgo(algo digest.Algorithm) Opts {
|
|
layerOpt := WithLayerDigestAlgo(algo)
|
|
configOpt := WithConfigDigestAlgo(algo)
|
|
manOpt := WithManifestDigestAlgo(algo)
|
|
return func(dc *dagConfig, dm *dagManifest) error {
|
|
err := layerOpt(dc, dm)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = configOpt(dc, dm)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = manOpt(dc, dm)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
}
|