Repository: regclient/regclient
Pull request #524 (2023-08-28 20:01:29)

Make mod time aware of base images
<!--

Commits must be signed indicating your agreement to the [DCO](https://developercertificate.org/).
See [DCO missing](https://github.com/src-d/guide/blob/master/developer-community/fix-DCO.md) for steps to fix a missing signoff.

-->

### Fixes issue

Changing timestamps by a "max time" option is limited. It doesn't allow times to be moved forward. And it could conflict with base images, particularly after rebasing on a newer base image.
<!-- If this is a bug fix, include "fixes #xxxx", or "closes #xxxx" -->

### Describe the change

Make the image mod timestamp methods aware of base images and allow those to be excluded from updates. Then allow timestamps to be set to a specific value, which can adjust the time either forward or backwards.
<!-- Include the type of change: bug fix, new feature, breaking change, documentation update -->
<!-- Describe what was changed, why the change was made, and how it was implemented -->

### How to verify it

Build of regclient reproducible images have been updated.

```shell
regctl image mod --help
```
<!-- Include steps that can be taken to verify the change -->

### Changelog text

- Image mod of timestamps is now aware of base images.
<!-- If the release changelog should have an entry for this, include it here -->

### Please verify and check that the pull request fulfills the following requirements

<!-- Mark the following with an [X] to verify they are included -->

- [X] Tests have been added or not applicable
- [X] Documentation has been added, updated, or not applicable
- [X] Changes have been rebased to main
- [X] Multiple commits to the same code have been squashed



Patch:
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index 5950f1e..04afaab 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -153,7 +153,6 @@ jobs:
         echo "Modifying image for reproducibility"
         regctl image mod "${local_tag}" --replace \
           --to-oci-referrers \
-          --time-max "${vcs_date}" \
           --annotation "[*]org.opencontainers.image.created=${vcs_date}" \
           --annotation "[*]org.opencontainers.image.source=${{ steps.prep.outputs.repo_url }}" \
           --annotation "[*]org.opencontainers.image.version=${{ steps.prep.outputs.version }}" \
@@ -161,10 +160,13 @@ jobs:
         if [ -n "$base_name" ] && [ -n "$base_digest" ]; then
           regctl image mod "${local_tag}" --replace \
             --annotation "[*]org.opencontainers.image.base.name=${base_name}" \
-            --annotation "[*]org.opencontainers.image.base.digest=${base_digest}"
+            --annotation "[*]org.opencontainers.image.base.digest=${base_digest}" \
+            --time "set=${vcs_date},base-ref=${base_name}@${base_digest}"
+        else
+          regctl image mod "${local_tag}" --replace \
+            --time "set=${vcs_date}"
         fi
         echo "digest=$(regctl image digest ${local_tag})" >>$GITHUB_OUTPUT
-
     - name: Attach SBOMs
       if: github.event_name != 'pull_request' && github.repository_owner == 'regclient'
       id: sbom
diff --git a/build/oci-image.sh b/build/oci-image.sh
index 17b6fa4..bb2f7fd 100755
--- a/build/oci-image.sh
+++ b/build/oci-image.sh
@@ -88,7 +88,6 @@ echo "Modding image"
 regctl image mod \
   "ocidir://output/${image}:${release}" --replace \
   --to-oci-referrers \
-  --time-max "${vcs_date}" \
   --annotation "[*]org.opencontainers.image.created=${vcs_date}" \
   --annotation "[*]org.opencontainers.image.source=${vcs_repo}" \
   --annotation "[*]org.opencontainers.image.version=${vcs_version}" \
@@ -100,6 +99,12 @@ if [ -n "$base_name" ] && [ -n "$base_digest" ]; then
     "ocidir://output/${image}:${release}" --replace \
     --annotation "[*]org.opencontainers.image.base.name=${base_name}" \
     --annotation "[*]org.opencontainers.image.base.digest=${base_digest}" \
+    --time "set=${vcs_date},base-ref=${base_name}@${base_digest}" \
+    >/dev/null
+else
+  regctl image mod \
+    "ocidir://output/${image}:${release}" --replace \
+    --time "set=${vcs_date}" \
     >/dev/null
 fi
 
diff --git a/cmd/regctl/image.go b/cmd/regctl/image.go
index 89228ea..4882583 100644
--- a/cmd/regctl/image.go
+++ b/cmd/regctl/image.go
@@ -127,9 +127,18 @@ var imageManifestCmd = &cobra.Command{
 	RunE:              runManifestGet,
 }
 var imageModCmd = &cobra.Command{
-	Use:               "mod <image_ref>",
-	Short:             "modify an image",
-	Long:              `EXPERIMENTAL: Applies requested modifications to an image`, // TODO: remove EXPERIMENTAL when stable
+	Use:   "mod <image_ref>",
+	Short: "modify an image",
+	// TODO: remove EXPERIMENTAL when stable
+	Long: `EXPERIMENTAL: Applies requested modifications to an image
+For time options, the value is a comma separated list of key/value pairs:
+  set=${time}: time to set in rfc3339 format, e.g. 2006-01-02T15:04:05Z
+  from-label=${label}: label used to extract time in rfc3339 format
+  after=${time_in_rfc3339}: adjust any time after this
+  base-ref=${image}: image to lookup base layers, which are skipped
+  base-layers=${count}: number of layers to skip changing (from the base image)
+  * set or from-label is required in the time options
+`,
 	Args:              cobra.ExactArgs(1),
 	ValidArgsFunction: completeArgTag,
 	RunE:              runImageMod,
@@ -295,6 +304,19 @@ func init() {
 			return nil
 		},
 	}, "buildarg-rm-regex", "", `delete a build arg with a regex value`)
+	imageModCmd.Flags().VarP(&modFlagFunc{
+		t: "string",
+		f: func(val string) error {
+			ot, err := imageParseOptTime(val)
+			if err != nil {
+				return err
+			}
+			imageOpts.modOpts = append(imageOpts.modOpts,
+				mod.WithConfigTimestamp(ot),
+			)
+			return nil
+		},
+	}, "config-time", "", `set timestamp for the config`)
 	imageModCmd.Flags().VarP(&modFlagFunc{
 		t: "string",
 		f: func(val string) error {
@@ -302,10 +324,15 @@ func init() {
 			if err != nil {
 				return fmt.Errorf("time must be formatted %s: %w", time.RFC3339, err)
 			}
-			imageOpts.modOpts = append(imageOpts.modOpts, mod.WithConfigTimestampMax(t))
+			imageOpts.modOpts = append(imageOpts.modOpts,
+				mod.WithConfigTimestamp(mod.OptTime{
+					Set:   t,
+					After: t,
+				}))
 			return nil
 		},
 	}, "config-time-max", "", `max timestamp for a config`)
+	imageModCmd.Flags().MarkHidden("config-time-max") // TODO: deprecate config-time-max in favor of config-time
 	imageModCmd.Flags().VarP(&modFlagFunc{
 		t: "stringArray",
 		f: func(val string) error {
@@ -418,6 +445,19 @@ func init() {
 			return nil
 		},
 	}, "layer-strip-file", "", `delete a file or directory from all layers`)
+	imageModCmd.Flags().VarP(&modFlagFunc{
+		t: "string",
+		f: func(val string) error {
+			ot, err := imageParseOptTime(val)
+			if err != nil {
+				return err
+			}
+			imageOpts.modOpts = append(imageOpts.modOpts,
+				mod.WithLayerTimestamp(ot),
+			)
+			return nil
+		},
+	}, "layer-time", "", `set timestamp for the layer contents`)
 	imageModCmd.Flags().VarP(&modFlagFunc{
 		t: "string",
 		f: func(val string) error {
@@ -425,10 +465,15 @@ func init() {
 			if err != nil {
 				return fmt.Errorf("time must be formatted %s: %w", time.RFC3339, err)
 			}
-			imageOpts.modOpts = append(imageOpts.modOpts, mod.WithLayerTimestampMax(t))
+			imageOpts.modOpts = append(imageOpts.modOpts, mod.WithLayerTimestamp(
+				mod.OptTime{
+					Set:   t,
+					After: t,
+				}))
 			return nil
 		},
 	}, "layer-time-max", "", `max timestamp for a layer`)
+	imageModCmd.Flags().MarkHidden("layer-time-max") // TODO: deprecate in favor of layer-time
 	flagRebase := imageModCmd.Flags().VarPF(&modFlagFunc{
 		t: "bool",
 		f: func(val string) error {
@@ -465,6 +510,20 @@ func init() {
 			return nil
 		},
 	}, "rebase-ref", "", `rebase an image with base references (base:old,base:new)`)
+	imageModCmd.Flags().VarP(&modFlagFunc{
+		t: "string",
+		f: func(val string) error {
+			ot, err := imageParseOptTime(val)
+			if err != nil {
+				return err
+			}
+			imageOpts.modOpts = append(imageOpts.modOpts,
+				mod.WithConfigTimestamp(ot),
+				mod.WithLayerTimestamp(ot),
+			)
+			return nil
+		},
+	}, "time", "", `set timestamp for both the config and layers`)
 	imageModCmd.Flags().VarP(&modFlagFunc{
 		t: "string",
 		f: func(val string) error {
@@ -473,11 +532,18 @@ func init() {
 				return fmt.Errorf("time must be formatted %s: %w", time.RFC3339, err)
 			}
 			imageOpts.modOpts = append(imageOpts.modOpts,
-				mod.WithConfigTimestampMax(t),
-				mod.WithLayerTimestampMax(t))
+				mod.WithConfigTimestamp(mod.OptTime{
+					Set:   t,
+					After: t,
+				}),
+				mod.WithLayerTimestamp(mod.OptTime{
+					Set:   t,
+					After: t,
+				}))
 			return nil
 		},
 	}, "time-max", "", `max timestamp for both the config and layers`)
+	imageModCmd.Flags().MarkHidden("time-max") // TODO: deprecate
 	flagDocker := imageModCmd.Flags().VarPF(&modFlagFunc{
 		t: "bool",
 		f: func(val string) error {
@@ -552,6 +618,45 @@ func init() {
 	rootCmd.AddCommand(imageCmd)
 }
 
+func imageParseOptTime(s string) (mod.OptTime, error) {
+	ot := mod.OptTime{}
+	for _, ss := range strings.Split(s, ",") {
+		kv := strings.SplitN(ss, "=", 2)
+		if len(kv) != 2 {
+			return ot, fmt.Errorf("parameter without a value: %s", ss)
+		}
+		switch kv[0] {
+		case "set":
+			t, err := time.Parse(time.RFC3339, kv[1])
+			if err != nil {
+				return ot, fmt.Errorf("set time must be formatted %s: %w", time.RFC3339, err)
+			}
+			ot.Set = t
+		case "after":
+			t, err := time.Parse(time.RFC3339, kv[1])
+			if err != nil {
+				return ot, fmt.Errorf("after time must be formatted %s: %w", time.RFC3339, err)
+			}
+			ot.After = t
+		case "from-label":
+			ot.FromLabel = kv[1]
+		case "base-ref":
+			r, err := ref.New(kv[1])
+			if err != nil {
+				return ot, fmt.Errorf("failed to parse base ref: %w", err)
+			}
+			ot.BaseRef = r
+		case "base-layers":
+			i, err := strconv.Atoi(kv[1])
+			if err != nil {
+				return ot, fmt.Errorf("unable to parse base layer count: %w", err)
+			}
+			ot.BaseLayers = i
+		}
+	}
+	return ot, nil
+}
+
 func runImageCheckBase(cmd *cobra.Command, args []string) error {
 	ctx := cmd.Context()
 	r, err := ref.New(args[0])
@@ -1094,7 +1199,7 @@ func runImageMod(cmd *cobra.Command, args []string) error {
 	if err != nil {
 		return err
 	}
-	fmt.Printf("%s\n", rOut.CommonName())
+	fmt.Fprintf(cmd.OutOrStdout(), "%s\n", rOut.CommonName())
 	rc.Close(ctx, rOut)
 	return nil
 }
diff --git a/mod/config.go b/mod/config.go
index 61577cd..1bb1314 100644
--- a/mod/config.go
+++ b/mod/config.go
@@ -8,6 +8,8 @@ import (
 	"time"
 
 	"github.com/regclient/regclient"
+	"github.com/regclient/regclient/types"
+	"github.com/regclient/regclient/types/manifest"
 	"github.com/regclient/regclient/types/ref"
 )
 
@@ -50,34 +52,102 @@ func WithBuildArgRm(arg string, value *regexp.Regexp) Opts {
 	}
 }
 
-// WithConfigTimestampFromLabel sets the max timestamp in the config to match a label value
-func WithConfigTimestampFromLabel(label string) Opts {
+// WithConfigTimestamp sets the timestamp on the config entries based on options
+func WithConfigTimestamp(optTime OptTime) Opts {
 	return func(dc *dagConfig, dm *dagManifest) error {
+		if optTime.Set.IsZero() && optTime.FromLabel == "" {
+			return fmt.Errorf("WithConfigTimestamp requires a time to set")
+		}
 		dc.stepsOCIConfig = append(dc.stepsOCIConfig, func(c context.Context, rc *regclient.RegClient, rSrc, rTgt ref.Ref, doc *dagOCIConfig) error {
-			var err error
-			changed := false
 			oc := doc.oc.GetConfig()
-			tl, ok := oc.Config.Labels[label]
-			if !ok {
-				return fmt.Errorf("label not found: %s", label)
-			}
-			t, err := time.Parse(time.RFC3339, tl)
-			if err != nil {
-				// TODO: add fallbacks
-				return fmt.Errorf("could not parse time %s from %s: %w", tl, label, err)
+			// lookup start time from label
+			if optTime.FromLabel != "" {
+				tl, ok := oc.Config.Labels[optTime.FromLabel]
+				if !ok {
+					return fmt.Errorf("label not found: %s", optTime.FromLabel)
+				}
+				tNew, err := time.Parse(time.RFC3339, tl)
+				if err != nil {
+					// TODO: add fallbacks
+					return fmt.Errorf("could not parse time %s from %s: %w", tl, optTime.FromLabel, err)
+				}
+				if !optTime.Set.IsZero() && !optTime.Set.Equal(tNew) {
+					return fmt.Errorf("conflicting time labels found %s and %s", optTime.Set.String(), tNew.String())
+				}
+				optTime.Set = tNew
 			}
-			if oc.Created != nil && t.Before(*oc.Created) {
-				*oc.Created = t
-				changed = true
+			startHistory := 0
+			// offset startHistory by base layer count
+			if optTime.BaseLayers > 0 {
+				layersCount := 0
+				for i, history := range oc.History {
+					if !history.EmptyLayer {
+						layersCount++
+					}
+					if layersCount == optTime.BaseLayers {
+						startHistory = i + 1
+						break
+					}
+				}
+				if startHistory == 0 {
+					startHistory = len(oc.History)
+				}
 			}
-			if oc.History != nil {
-				for i, h := range oc.History {
-					if h.Created != nil && t.Before(*h.Created) {
-						*oc.History[i].Created = t
-						changed = true
+			// offset startHistory from base image history
+			if !optTime.BaseRef.IsZero() {
+				var d types.Descriptor
+				for {
+					mOpts := []regclient.ManifestOpts{}
+					if d.Digest != "" {
+						mOpts = append(mOpts, regclient.WithManifestDesc(d))
 					}
+					m, err := rc.ManifestGet(c, optTime.BaseRef, mOpts...)
+					if err != nil {
+						return fmt.Errorf("unable to get base image: %w", err)
+					}
+					if mi, ok := m.(manifest.Imager); ok {
+						cd, err := mi.GetConfig()
+						if err != nil {
+							return fmt.Errorf("unable to get base image config descriptor: %w", err)
+						}
+						d = cd
+						break
+					} else if _, ok := m.(manifest.Indexer); ok {
+						pd, err := manifest.GetPlatformDesc(m, &oc.Platform)
+						if err != nil {
+							return fmt.Errorf("unable to get base image platform %s: %w", oc.Platform.String(), err)
+						}
+						d = *pd
+					} else {
+						return fmt.Errorf("unsupported base image manifest")
+					}
+				}
+				baseConfig, err := rc.BlobGetOCIConfig(c, optTime.BaseRef, d)
+				if err != nil {
+					return fmt.Errorf("failed to get base image config: %w", err)
+				}
+				// exclude matching history lines from base image
+				for i, history := range baseConfig.GetConfig().History {
+					if len(oc.History) <= i ||
+						oc.History[i].Author != history.Author ||
+						oc.History[i].Comment != history.Comment ||
+						!oc.History[i].Created.Equal(*history.Created) ||
+						oc.History[i].CreatedBy != history.CreatedBy ||
+						oc.History[i].EmptyLayer != history.EmptyLayer {
+						break
+					}
+					startHistory = i + 1
 				}
 			}
+			var changed, cCur bool
+			// adjust created time on created and history fields
+			if oc.Created != nil {
+				*oc.Created, changed = timeModOpt(*oc.Created, optTime)
+			}
+			for i := startHistory; i < len(oc.History); i++ {
+				*oc.History[i].Created, cCur = timeModOpt(*oc.History[i].Created, optTime)
+				changed = changed || cCur
+			}
 			if changed {
 				doc.oc.SetConfig(oc)
 				doc.newDesc = doc.oc.GetDescriptor()
@@ -89,12 +159,24 @@ func WithConfigTimestampFromLabel(label string) Opts {
 	}
 }
 
-// WithConfigTimestampMax sets the max timestamp on any config objects
-func WithConfigTimestampMax(t time.Time) Opts {
+// WithConfigTimestampFromLabel sets the max timestamp in the config to match a label value
+//
+// Deprecated: replace with WithConfigTimestamp
+func WithConfigTimestampFromLabel(label string) Opts {
 	return func(dc *dagConfig, dm *dagManifest) error {
 		dc.stepsOCIConfig = append(dc.stepsOCIConfig, func(c context.Context, rc *regclient.RegClient, rSrc, rTgt ref.Ref, doc *dagOCIConfig) error {
+			var err error
 			changed := false
 			oc := doc.oc.GetConfig()
+			tl, ok := oc.Config.Labels[label]
+			if !ok {
+				return fmt.Errorf("label not found: %s", label)
+			}
+			t, err := time.Parse(time.RFC3339, tl)
+			if err != nil {
+				// TODO: add fallbacks
+				return fmt.Errorf("could not parse time %s from %s: %w", tl, label, err)
+			}
 			if oc.Created != nil && t.Before(*oc.Created) {
 				*oc.Created = t
 				changed = true
@@ -118,6 +200,16 @@ func WithConfigTimestampMax(t time.Time) Opts {
 	}
 }
 
+// WithConfigTimestampMax sets the max timestamp on any config objects
+//
+// Deprecated: replace with WithConfigTimestamp
+func WithConfigTimestampMax(t time.Time) Opts {
+	return WithConfigTimestamp(OptTime{
+		Set:   t,
+		After: t,
+	})
+}
+
 // WithExposeAdd defines an exposed port in the image config
 func WithExposeAdd(port string) Opts {
 	return func(dc *dagConfig, dm *dagManifest) error {
diff --git a/mod/dag.go b/mod/dag.go
index c7971f9..340f5c1 100644
--- a/mod/dag.go
+++ b/mod/dag.go
@@ -441,14 +441,14 @@ func dagWalkManifests(dm *dagManifest, fn func(*dagManifest) (*dagManifest, erro
 			}
 		}
 	}
-	if dm.referrers != nil {
-		for _, child := range dm.referrers {
-			err := dagWalkManifests(child, fn)
-			if err != nil {
-				return err
-			}
-		}
-	}
+	// if dm.referrers != nil {
+	// 	for _, child := range dm.referrers {
+	// 		err := dagWalkManifests(child, fn)
+	// 		if err != nil {
+	// 			return err
+	// 		}
+	// 	}
+	// }
 	mmNew, err := fn(dm)
 	if err != nil {
 		return err
@@ -466,14 +466,14 @@ func dagWalkOCIConfig(dm *dagManifest, fn func(*dagOCIConfig) (*dagOCIConfig, er
 			}
 		}
 	}
-	if dm.referrers != nil {
-		for _, child := range dm.referrers {
-			err := dagWalkOCIConfig(child, fn)
-			if err != nil {
-				return err
-			}
-		}
-	}
+	// if dm.referrers != nil {
+	// 	for _, child := range dm.referrers {
+	// 		err := dagWalkOCIConfig(child, fn)
+	// 		if err != nil {
+	// 			return err
+	// 		}
+	// 	}
+	// }
 	if dm.config != nil {
 		docNew, err := fn(dm.config)
 		if err != nil {
@@ -494,14 +494,14 @@ func dagWalkLayers(dm *dagManifest, fn func(*dagLayer) (*dagLayer, error)) error
 			}
 		}
 	}
-	if dm.referrers != nil {
-		for _, child := range dm.referrers {
-			err = dagWalkLayers(child, fn)
-			if err != nil {
-				return err
-			}
-		}
-	}
+	// if dm.referrers != nil {
+	// 	for _, child := range dm.referrers {
+	// 		err = dagWalkLayers(child, fn)
+	// 		if err != nil {
+	// 			return err
+	// 		}
+	// 	}
+	// }
 	if dm.layers != nil {
 		for i, layer := range dm.layers {
 			if layer.mod == deleted {
diff --git a/mod/layer.go b/mod/layer.go
index 8e9542d..946c0c1 100644
--- a/mod/layer.go
+++ b/mod/layer.go
@@ -11,7 +11,9 @@ import (
 	"strings"
 	"time"
 
+	"github.com/opencontainers/go-digest"
 	"github.com/regclient/regclient"
+	"github.com/regclient/regclient/types/manifest"
 	"github.com/regclient/regclient/types/ref"
 )
 
@@ -114,7 +116,144 @@ func WithLayerStripFile(file string) Opts {
 	}
 }
 
+// WithLayerTimestamp sets the timestamp on files in the layers based on options
+func WithLayerTimestamp(optTime OptTime) Opts {
+	return func(dc *dagConfig, dm *dagManifest) error {
+		if optTime.Set.IsZero() && optTime.FromLabel == "" {
+			return fmt.Errorf("WithLayerTimestamp requires a time to set")
+		}
+		baseProcessed := false
+		baseDigests := map[digest.Digest]bool{}
+		// add base layers by count
+		if optTime.BaseLayers > 0 {
+			dl, err := layerGetBaseCount(optTime.BaseLayers, dm)
+			if err != nil {
+				return fmt.Errorf("failed to get base layers: %w", err)
+			}
+			for _, d := range dl {
+				baseDigests[d] = true
+			}
+		}
+		if optTime.FromLabel != "" {
+			dc.stepsOCIConfig = append(dc.stepsOCIConfig, func(c context.Context, rc *regclient.RegClient, rSrc, rTgt ref.Ref, doc *dagOCIConfig) error {
+				oc := doc.oc.GetConfig()
+				tl, ok := oc.Config.Labels[optTime.FromLabel]
+				if !ok {
+					return fmt.Errorf("label not found: %s", optTime.FromLabel)
+				}
+				tNew, err := time.Parse(time.RFC3339, tl)
+				if err != nil {
+					// TODO: add fallbacks
+					return fmt.Errorf("could not parse time %s from %s: %w", tl, optTime.FromLabel, err)
+				}
+				if !optTime.Set.IsZero() && !optTime.Set.Equal(tNew) {
+					return fmt.Errorf("conflicting time labels found %s and %s", optTime.Set.String(), tNew.String())
+				}
+				optTime.Set = tNew
+				return nil
+			})
+		}
+		dc.stepsLayerFile = append(dc.stepsLayerFile,
+			func(c context.Context, rc *regclient.RegClient, rSrc, rTgt ref.Ref, dl *dagLayer, th *tar.Header, tr io.Reader) (*tar.Header, io.Reader, changes, error) {
+				if optTime.Set.IsZero() {
+					return nil, nil, unchanged, fmt.Errorf("timestamp not available")
+				}
+				// for base ref, lookup all digests from base image to exclude
+				if !baseProcessed {
+					if !optTime.BaseRef.IsZero() {
+						m, err := rc.ManifestGet(c, optTime.BaseRef)
+						if err != nil {
+							return nil, nil, unchanged, fmt.Errorf("failed to get base image: %w", err)
+						}
+						dl, err := layerGetBaseRef(c, rc, optTime.BaseRef, m)
+						if err != nil {
+							return nil, nil, unchanged, fmt.Errorf("failed to get base layers: %w", err)
+						}
+						for _, d := range dl {
+							baseDigests[d] = true
+						}
+					}
+					baseProcessed = true
+				}
+				// skip layers from base image
+				if baseDigests[dl.desc.Digest] {
+					return th, tr, unchanged, nil
+				}
+				if th == nil || tr == nil {
+					return nil, nil, unchanged, fmt.Errorf("missing header or reader")
+				}
+				var ca, cc, cm bool
+				// do not mod times that are currently zero, underlying tar format may not support changing
+				if !th.AccessTime.IsZero() {
+					th.AccessTime, ca = timeModOpt(th.AccessTime, optTime)
+				}
+				if !th.ChangeTime.IsZero() {
+					th.ChangeTime, cc = timeModOpt(th.ChangeTime, optTime)
+				}
+				if !th.ModTime.IsZero() {
+					th.ModTime, cm = timeModOpt(th.ModTime, optTime)
+				}
+				if ca || cc || cm {
+					return th, tr, replaced, nil
+				}
+				return th, tr, unchanged, nil
+			},
+		)
+		return nil
+	}
+}
+
+func layerGetBaseCount(count int, dm *dagManifest) ([]digest.Digest, error) {
+	dl := []digest.Digest{}
+	for _, dmChild := range dm.manifests {
+		dlChild, err := layerGetBaseCount(count, dmChild)
+		if err != nil {
+			return dl, err
+		}
+		dl = append(dl, dlChild...)
+	}
+	for i, layer := range dm.layers {
+		if i < count {
+			dl = append(dl, layer.desc.Digest)
+		}
+	}
+	return dl, nil
+}
+
+func layerGetBaseRef(c context.Context, rc *regclient.RegClient, r ref.Ref, m manifest.Manifest) ([]digest.Digest, error) {
+	dl := []digest.Digest{}
+	if mi, ok := m.(manifest.Indexer); ok {
+		ml, err := mi.GetManifestList()
+		if err != nil {
+			return dl, err
+		}
+		for _, d := range ml {
+			mChild, err := rc.ManifestGet(c, r, regclient.WithManifestDesc(d))
+			if err != nil {
+				return dl, err
+			}
+			dlChild, err := layerGetBaseRef(c, rc, r, mChild)
+			if err != nil {
+				return dl, err
+			}
+			dl = append(dl, dlChild...)
+		}
+	}
+	if mi, ok := m.(manifest.Imager); ok {
+		layers, err := mi.GetLayers()
+		if err != nil {
+			return dl, err
+		}
+		for _, l := range layers {
+			dl = append(dl, l.Digest)
+		}
+	}
+	return dl, nil
+}
+
 // WithLayerTimestampFromLabel sets the max layer timestamp based on a label in the image
+//
+// Deprecated: replace with WithLayerTimestamp
 func WithLayerTimestampFromLabel(label string) Opts {
 	t := time.Time{}
 	return func(dc *dagConfig, dm *dagManifest) error {
@@ -167,34 +306,13 @@ func WithLayerTimestampFromLabel(label string) Opts {
 }
 
 // WithLayerTimestampMax ensures no file timestamps are after specified time
+//
+// Deprecated: replace with WithLayerTimestamp
 func WithLayerTimestampMax(t time.Time) Opts {
-	return func(dc *dagConfig, dm *dagManifest) error {
-		dc.stepsLayerFile = append(dc.stepsLayerFile,
-			func(c context.Context, rc *regclient.RegClient, rSrc, rTgt ref.Ref, dl *dagLayer, th *tar.Header, tr io.Reader) (*tar.Header, io.Reader, changes, error) {
-				changed := false
-				if th == nil || tr == nil {
-					return nil, nil, unchanged, fmt.Errorf("missing header or reader")
-				}
-				if t.Before(th.AccessTime) {
-					th.AccessTime = t
-					changed = true
-				}
-				if t.Before(th.ChangeTime) {
-					th.ChangeTime = t
-					changed = true
-				}
-				if t.Before(th.ModTime) {
-					th.ModTime = t
-					changed = true
-				}
-				if changed {
-					return th, tr, replaced, nil
-				}
-				return th, tr, unchanged, nil
-			},
-		)
-		return nil
-	}
+	return WithLayerTimestamp(OptTime{
+		Set:   t,
+		After: t,
+	})
 }
 
 // WithFileTarTimeMax processes a tar file within a layer and rewrites the contents with a max timestamp
diff --git a/mod/mod.go b/mod/mod.go
index 1ed5792..87e62f5 100644
--- a/mod/mod.go
+++ b/mod/mod.go
@@ -7,6 +7,7 @@ import (
 	"context"
 	"io"
 	"os"
+	"time"
 
 	"github.com/opencontainers/go-digest"
 	"github.com/regclient/regclient"
@@ -18,6 +19,15 @@ import (
 // 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 (
 	// whitelist of tar media types
 	mtWLTar = []string{
diff --git a/mod/time.go b/mod/time.go
index f43ca45..faed048 100644
--- a/mod/time.go
+++ b/mod/time.go
@@ -33,3 +33,15 @@ func timeEpocEnv() (time.Time, error) {
 	}
 	return time.Unix(secI, 0), nil
 }
+
+// timeModOpt adjusts time t according to the opts.
+// The bool indicates if the time was changed.
+func timeModOpt(t time.Time, opt OptTime) (time.Time, bool) {
+	if !opt.After.IsZero() && !t.After(opt.After) {
+		return t, false
+	}
+	if !t.Equal(opt.Set) {
+		return opt.Set, true
+	}
+	return t, false
+}


Test patch:
diff --git a/cmd/regctl/image_test.go b/cmd/regctl/image_test.go
index 74104ac..45839a3 100644
--- a/cmd/regctl/image_test.go
+++ b/cmd/regctl/image_test.go
@@ -43,3 +43,22 @@ func TestImageExportImport(t *testing.T) {
 		t.Errorf("unexpected output: %v", out)
 	}
 }
+
+func TestImageMod(t *testing.T) {
+	tmpDir := t.TempDir()
+	srcRef := "ocidir://../../testdata/testrepo:v3"
+	baseRef := "ocidir://../../testdata/testrepo:b1"
+	modRef := fmt.Sprintf("ocidir://%s/repo:mod", tmpDir)
+	saveOpts := imageOpts
+
+	out, err := cobraTest(t, "image", "mod", srcRef, "--create", modRef, "--time", "set=2000-01-01T00:00:00Z,base-ref="+baseRef)
+	imageOpts = saveOpts
+	if err != nil {
+		t.Errorf("failed to run image import: %v", err)
+		return
+	}
+	if out == "" {
+		t.Errorf("missing output")
+	}
+
+}
diff --git a/mod/mod_test.go b/mod/mod_test.go
index f49dbd6..d4abfb0 100644
--- a/mod/mod_test.go
+++ b/mod/mod_test.go
@@ -235,6 +235,74 @@ func TestMod(t *testing.T) {
 			ref:      "ocidir://testrepo:v1",
 			wantSame: true,
 		},
+		{
+			name: "Config Time Missing Set",
+			opts: []Opts{
+				WithConfigTimestamp(OptTime{}),
+			},
+			ref:     "ocidir://testrepo:v1",
+			wantErr: fmt.Errorf("WithConfigTimestamp requires a time to set"),
+		},
+		{
+			name: "Config Time Set",
+			opts: []Opts{
+				WithConfigTimestamp(OptTime{
+					Set: tTime,
+				}),
+			},
+			ref: "ocidir://testrepo:v1",
+		},
+		{
+			name: "Config Time Base Ref",
+			opts: []Opts{
+				WithConfigTimestamp(OptTime{
+					Set:     tTime,
+					BaseRef: rb1,
+				}),
+			},
+			ref: "ocidir://testrepo:v3",
+		},
+		{
+			name: "Config Time Base Count",
+			opts: []Opts{
+				WithConfigTimestamp(OptTime{
+					Set:        tTime,
+					BaseLayers: 1,
+				}),
+			},
+			ref: "ocidir://testrepo:v3",
+		},
+		{
+			name: "Config Time Label Missing",
+			opts: []Opts{
+				WithConfigTimestamp(OptTime{
+					FromLabel: "org.opencontainers.image.created",
+				}),
+			},
+			ref:     "ocidir://testrepo:v1",
+			wantErr: fmt.Errorf("label not found: org.opencontainers.image.created"),
+		},
+		{
+			name: "Config Time Artifact",
+			opts: []Opts{
+				WithConfigTimestamp(OptTime{
+					Set: tTime,
+				}),
+			},
+			ref:      "ocidir://testrepo:a1",
+			wantSame: true,
+		},
+		{
+			name: "Config Time After Unchanged",
+			opts: []Opts{
+				WithConfigTimestamp(OptTime{
+					Set:   tTime,
+					After: time.Now(),
+				}),
+			},
+			ref:      "ocidir://testrepo:v1",
+			wantSame: true,
+		},
 		{
 			name: "Expose Port",
 			opts: []Opts{
@@ -304,6 +372,97 @@ func TestMod(t *testing.T) {
 			},
 			ref: "ocidir://testrepo:v3",
 		},
+		{
+			name: "Layer Timestamp Set Missing",
+			opts: []Opts{
+				WithLayerTimestamp(OptTime{}),
+			},
+			ref:     "ocidir://testrepo:v1",
+			wantErr: fmt.Errorf("WithLayerTimestamp requires a time to set"),
+		},
+		{
+			name: "Layer Timestamp Missing Label",
+			opts: []Opts{
+				WithLayerTimestamp(OptTime{
+					FromLabel: "missing",
+				}),
+			},
+			ref:     "ocidir://testrepo:v1",
+			wantErr: fmt.Errorf("label not found: missing"),
+		},
+		{
+			name: "Layer Timestamp",
+			opts: []Opts{
+				WithLayerTimestamp(OptTime{
+					Set: tTime,
+				}),
+			},
+			ref: "ocidir://testrepo:v1",
+		},
+		{
+			name: "Layer Timestamp After Unchanged",
+			opts: []Opts{
+				WithLayerTimestamp(OptTime{
+					Set:   tTime,
+					After: time.Now(),
+				}),
+			},
+			ref:      "ocidir://testrepo:v1",
+			wantSame: true,
+		},
+		{
+			name: "Layer Timestamp Base Ref",
+			opts: []Opts{
+				WithLayerTimestamp(OptTime{
+					Set:     tTime,
+					BaseRef: rb1,
+				}),
+			},
+			ref: "ocidir://testrepo:v3",
+		},
+		{
+			name: "Layer Timestamp Base Ref Same",
+			opts: []Opts{
+				WithLayerTimestamp(OptTime{
+					Set:     tTime,
+					BaseRef: r3,
+				}),
+			},
+			ref:      "ocidir://testrepo:v3",
+			wantSame: true,
+		},
+		{
+			name: "Layer Timestamp Base Count Same",
+			opts: []Opts{
+				WithLayerTimestamp(OptTime{
+					Set:        tTime,
+					BaseLayers: 99,
+				}),
+			},
+			ref:      "ocidir://testrepo:v3",
+			wantSame: true,
+		},
+		{
+			name: "Layer Timestamp Base Count",
+			opts: []Opts{
+				WithLayerTimestamp(OptTime{
+					Set:        tTime,
+					BaseLayers: 1,
+				}),
+			},
+			ref: "ocidir://testrepo:v3",
+		},
+		{
+			name: "Layer Timestamp Artifact",
+			opts: []Opts{
+				WithLayerTimestamp(OptTime{
+					Set:   tTime,
+					After: tTime,
+				}),
+			},
+			ref:      "ocidir://testrepo:a1",
+			wantSame: true,
+		},
 		{
 			name: "Layer File Tar Timestamp",
 			opts: []Opts{