build, commit: set the OCI ...created annotation on OCI images

When building or committing an image in OCI format, default to setting
the org.opencontainers.image.created annotation to the value used in the
image's config blob for the image's creation date. The behavior can be
controlled using the new --created-annotation flag.

Add --annotation and --unsetannotation flags to `buildah commit` which
mimic the same flags for `buildah build`.

Signed-off-by: Nalin Dahyabhai <nalin@redhat.com>
This commit is contained in:
Nalin Dahyabhai 2025-06-20 15:05:20 -04:00
parent e6375b3c28
commit 5968d82047
14 changed files with 201 additions and 17 deletions

View File

@ -66,6 +66,9 @@ type commitInputOptions struct {
encryptLayers []int
unsetenvs []string
addFile []string
unsetAnnotation []string
annotation []string
createdAnnotation bool
}
func init() {
@ -187,6 +190,11 @@ func commitListFlagSet(cmd *cobra.Command, opts *commitInputOptions) {
flags.StringSliceVar(&opts.unsetenvs, "unsetenv", nil, "unset env from final image")
_ = cmd.RegisterFlagCompletionFunc("unsetenv", completion.AutocompleteNone)
flags.StringSliceVar(&opts.unsetAnnotation, "unsetannotation", nil, "unset annotation when inheriting annotations from base image")
_ = cmd.RegisterFlagCompletionFunc("unsetannotation", completion.AutocompleteNone)
flags.StringArrayVar(&opts.annotation, "annotation", []string{}, "set metadata for an image (default [])")
_ = cmd.RegisterFlagCompletionFunc("annotation", completion.AutocompleteNone)
flags.BoolVar(&opts.createdAnnotation, "created-annotation", true, `set an "org.opencontainers.image.created" annotation in the image`)
}
func commitCmd(c *cobra.Command, args []string, iopts commitInputOptions) error {
@ -311,6 +319,9 @@ func commitCmd(c *cobra.Command, args []string, iopts commitInputOptions) error
OverrideChanges: iopts.changes,
OverrideConfig: overrideConfig,
ExtraImageContent: addFiles,
UnsetAnnotations: iopts.unsetAnnotation,
Annotations: iopts.annotation,
CreatedAnnotation: types.NewOptionalBool(iopts.createdAnnotation),
}
exclusiveFlags := 0
if c.Flag("reference-time").Changed {

View File

@ -104,7 +104,8 @@ type CommitOptions struct {
OmitLayerHistoryEntry bool
// OmitTimestamp forces epoch 0 as created timestamp to allow for
// deterministic, content-addressable builds.
// Deprecated use HistoryTimestamp instead.
// Deprecated: use HistoryTimestamp or SourceDateEpoch (possibly with
// RewriteTimestamp) instead.
OmitTimestamp bool
// SignBy is the fingerprint of a GPG key to use for signing the image.
SignBy string
@ -130,7 +131,8 @@ type CommitOptions struct {
// contents of a rootfs.
ConfidentialWorkloadOptions ConfidentialWorkloadOptions
// UnsetEnvs is a list of environments to not add to final image.
// Deprecated: use UnsetEnv() before committing instead.
// Deprecated: use UnsetEnv() before committing, or set OverrideChanges
// instead.
UnsetEnvs []string
// OverrideConfig is an optional Schema2Config which can override parts
// of the working container's configuration for the image that is being
@ -167,6 +169,15 @@ type CommitOptions struct {
// corresponding members in the Builder object, in the committed image
// is not guaranteed.
PrependedLinkedLayers, AppendedLinkedLayers []LinkedLayer
// UnsetAnnotations is a list of annotations (names only) to withhold
// from the image.
UnsetAnnotations []string
// Annotations is a list of annotations (in the form "key=value") to
// add to the image.
Annotations []string
// CreatedAnnotation controls whether or not an "org.opencontainers.image.created"
// annotation is present in the output image.
CreatedAnnotation types.OptionalBool
}
// LinkedLayer combines a history entry with the location of either a directory

View File

@ -49,7 +49,8 @@ type CommonBuildOptions struct {
CPUSetMems string
// HTTPProxy determines whether *_proxy env vars from the build host are passed into the container.
HTTPProxy bool
// IdentityLabel if set ensures that default `io.buildah.version` label is not applied to build image.
// IdentityLabel if set controls whether or not a `io.buildah.version` label is added to the built image.
// Setting this to false does not clear the label if it would be inherited from the base image.
IdentityLabel types.OptionalBool
// Memory is the upper limit (in bytes) on how much memory running containers can use.
Memory int64
@ -414,4 +415,7 @@ type BuildOptions struct {
CompatLayerOmissions types.OptionalBool
// NoPivotRoot inhibits the usage of pivot_root when setting up the rootfs
NoPivotRoot bool
// CreatedAnnotation controls whether or not an "org.opencontainers.image.created"
// annotation is present in the output image.
CreatedAnnotation types.OptionalBool
}

View File

@ -297,6 +297,16 @@ If you have four memory nodes on your system (0-3), use `--cpuset-mems=0,1`
then processes in your container will only use memory from the first
two memory nodes.
**--created-annotation**
Add an image *annotation* (see also **--annotation**) to the image metadata
setting "org.opencontainers.image.created" to the current time, or to the
datestamp specified to the **--source-date-epoch** or **--timestamp** flag,
if either was used. If *false*, no such annotation will be present in the
written image.
Note: this information is not present in Docker image formats, so it is discarded when writing images in Docker formats.
**--creds** *creds*
The [username[:password]] to use to authenticate with the registry if required.
@ -508,6 +518,8 @@ than once, attempting to use this option will trigger an error.
**--inherit-annotations** *bool-value*
Inherit the annotations from the base image or base stages. (default true).
Use cases which set this flag to *false* may need to do the same for the
**--created-annotation** flag.
**--inherit-labels** *bool-value*

View File

@ -29,6 +29,13 @@ will be used. The new file will be owned by UID 0, GID 0, have 0644
permissions, and be given the timestamp specified to the **--timestamp** option
if it is specified. This option can be specified multiple times.
**--annotation** *annotation[=value]*
Add an image *annotation* (e.g. annotation=*value*) to the image metadata. Can be used multiple times.
If *annotation* is named, but neither `=` nor a `value` is provided, then the *annotation* is set to an empty value.
Note: this information is not present in Docker image formats, so it is discarded when writing images in Docker formats.
**--authfile** *path*
Path of the authentication file. Default is ${XDG_RUNTIME_DIR}/containers/auth.json. See containers-auth.json(5) for more information. This file is created using `buildah login`.
@ -55,6 +62,16 @@ Read a JSON-encoded version of an image configuration object from the specified
file, and merge the values from it with the configuration of the image being
committed.
**--created-annotation**
Add an image *annotation* (see also **--annotation**) to the image metadata
setting "org.opencontainers.image.created" to the current time, or to the
datestamp specified to the **--source-date-epoch** or **--timestamp** flag,
if either was used. If *false*, no such annotation will be present in the
written image.
Note: this information is not present in Docker image formats, so it is discarded when writing images in Docker formats.
**--creds** *creds*
The [username[:password]] to use to authenticate with the registry if required.
@ -350,6 +367,10 @@ not affect the timestamps of layer contents.
Require HTTPS and verification of certificates when talking to container registries (defaults to true). TLS verification cannot be used when talking to an insecure registry.
**--unsetannotation** *annotation*
Unset the image annotation, causing the annotation not to be inherited from the base image.
**--unsetenv** *env*
Unset environment variables from the final image.

View File

@ -104,6 +104,9 @@ type containerImageRef struct {
extraImageContent map[string]string
compatSetParent types.OptionalBool
layerExclusions []copier.ConditionalRemovePath
unsetAnnotations []string
setAnnotations []string
createdAnnotation types.OptionalBool
}
type blobLayerInfo struct {
@ -590,6 +593,23 @@ func (i *containerImageRef) newOCIManifestBuilder() (manifestBuilder, error) {
}
// Return partial manifest. The Layers lists will be populated later.
annotations := make(map[string]string)
maps.Copy(annotations, i.annotations)
switch i.createdAnnotation {
case types.OptionalBoolFalse:
delete(annotations, v1.AnnotationCreated)
default:
fallthrough
case types.OptionalBoolTrue, types.OptionalBoolUndefined:
annotations[v1.AnnotationCreated] = created.UTC().Format(time.RFC3339Nano)
}
for _, k := range i.unsetAnnotations {
delete(annotations, k)
}
for _, kv := range i.setAnnotations {
k, v, _ := strings.Cut(kv, "=")
annotations[k] = v
}
return &ociManifestBuilder{
i: i,
// The default layer media type assumes no compression.
@ -604,7 +624,7 @@ func (i *containerImageRef) newOCIManifestBuilder() (manifestBuilder, error) {
MediaType: v1.MediaTypeImageConfig,
},
Layers: []v1.Descriptor{},
Annotations: i.annotations,
Annotations: annotations,
},
}, nil
}
@ -1525,6 +1545,8 @@ func (b *Builder) makeContainerImageRef(options CommitOptions) (*containerImageR
layerLatestModTime: layerLatestModTime,
historyComment: b.HistoryComment(),
annotations: b.Annotations(),
setAnnotations: slices.Clone(options.Annotations),
unsetAnnotations: slices.Clone(options.UnsetAnnotations),
preferredManifestType: manifestType,
squash: options.Squash,
confidentialWorkload: options.ConfidentialWorkloadOptions,
@ -1543,6 +1565,7 @@ func (b *Builder) makeContainerImageRef(options CommitOptions) (*containerImageR
extraImageContent: maps.Clone(options.ExtraImageContent),
compatSetParent: options.CompatSetParent,
layerExclusions: layerExclusions,
createdAnnotation: options.CreatedAnnotation,
}
if ref.created != nil {
for i := range ref.preEmptyLayers {

View File

@ -171,6 +171,7 @@ type Executor struct {
noPivotRoot bool
sourceDateEpoch *time.Time
rewriteTimestamp bool
createdAnnotation types.OptionalBool
}
type imageTypeAndHistoryAndDiffIDs struct {
@ -342,6 +343,7 @@ func newExecutor(logger *logrus.Logger, logPrefix string, store storage.Store, o
noPivotRoot: options.NoPivotRoot,
sourceDateEpoch: options.SourceDateEpoch,
rewriteTimestamp: options.RewriteTimestamp,
createdAnnotation: options.CreatedAnnotation,
}
// sort unsetAnnotations because we will later write these
// values to the history of the image therefore we want to

View File

@ -2505,6 +2505,9 @@ func (s *StageExecutor) commit(ctx context.Context, createdBy string, emptyLayer
SourceDateEpoch: s.executor.sourceDateEpoch,
RewriteTimestamp: s.executor.rewriteTimestamp,
CompatLayerOmissions: s.executor.compatLayerOmissions,
UnsetAnnotations: s.executor.unsetAnnotations,
Annotations: s.executor.annotations,
CreatedAnnotation: s.executor.createdAnnotation,
}
if finalInstruction {
options.ConfidentialWorkloadOptions = s.executor.confidentialWorkload

View File

@ -378,6 +378,7 @@ func GenBuildOptions(c *cobra.Command, inputArgs []string, iopts BuildOptions) (
Compression: compression,
ConfigureNetwork: networkPolicy,
ContextDirectory: contextDir,
CreatedAnnotation: types.NewOptionalBool(iopts.CreatedAnnotation),
Devices: iopts.Devices,
DropCapabilities: iopts.CapDrop,
Err: stderr,

View File

@ -127,6 +127,7 @@ type BudResults struct {
CompatVolumes bool
SourceDateEpoch string
RewriteTimestamp bool
CreatedAnnotation bool
}
// FromAndBugResults represents the results for common flags
@ -240,6 +241,7 @@ func GetBudFlags(flags *BudResults) pflag.FlagSet {
fs.BoolVar(&flags.InheritLabels, "inherit-labels", true, "inherit the labels from the base image or base stages.")
fs.BoolVar(&flags.InheritAnnotations, "inherit-annotations", true, "inherit the annotations from the base image or base stages.")
fs.StringArrayVar(&flags.CPPFlags, "cpp-flag", []string{}, "set additional flag to pass to C preprocessor (cpp)")
fs.BoolVar(&flags.CreatedAnnotation, "created-annotation", true, `set an "org.opencontainers.image.created" annotation in the image`)
fs.StringVar(&flags.Creds, "creds", "", "use `[username[:password]]` for accessing the registry")
fs.StringVarP(&flags.CWOptions, "cw", "", "", "confidential workload `options`")
fs.BoolVarP(&flags.DisableCompression, "disable-compression", "D", true, "don't compress layers by default")
@ -326,7 +328,7 @@ newer: only pull base and SBOM scanner images when newer images exist on the r
fs.String("variant", "", "override the `variant` of the specified image")
fs.StringSliceVar(&flags.UnsetEnvs, "unsetenv", nil, "unset environment variable from final image")
fs.StringSliceVar(&flags.UnsetLabels, "unsetlabel", nil, "unset label when inheriting labels from base image")
fs.StringSliceVar(&flags.UnsetAnnotations, "unsetannotation", nil, "unset annotation when inheriting annotation from base image")
fs.StringSliceVar(&flags.UnsetAnnotations, "unsetannotation", nil, "unset annotation when inheriting annotations from base image")
return fs
}

View File

@ -161,7 +161,7 @@ _EOF
assert "$output" != "$not_want_output" "expected some annotations to be set in base image $base"
## Since we are inheriting no annotations, built image should not contain any annotations.
run_buildah build $WITH_POLICY_JSON --inherit-annotations=false -t $target --from $base $BUDFILES/base-with-labels
run_buildah build $WITH_POLICY_JSON --inherit-annotations=false --created-annotation=false -t $target --from $base $BUDFILES/base-with-labels
# no annotations should be inherited from base image
want_output='map[]'
@ -169,7 +169,7 @@ _EOF
expect_output "$want_output"
## Build again but set a new annotation and don't inherit annotations from base image
run_buildah build $WITH_POLICY_JSON --inherit-annotations=false --annotation hello=world -t $target --from $base $BUDFILES/base-with-labels
run_buildah build $WITH_POLICY_JSON --inherit-annotations=false --created-annotation=false --annotation hello=world -t $target --from $base $BUDFILES/base-with-labels
# no annotations should be inherited from base image
want_output='map["hello":"world"]'
@ -177,13 +177,13 @@ _EOF
expect_output "$want_output"
## Try similar thing with another Containerfile
run_buildah build $WITH_POLICY_JSON --inherit-annotations=false -t $target -f $BUDFILES/base-with-labels/Containerfile2
run_buildah build $WITH_POLICY_JSON --inherit-annotations=false --created-annotation=false -t $target -f $BUDFILES/base-with-labels/Containerfile2
# no annotations should be inherited from base image
want_output='map[]'
run_buildah inspect --format '{{printf "%q" .ImageAnnotations}}' $target
expect_output "$want_output"
run_buildah build $WITH_POLICY_JSON --inherit-annotations=false --annotation hello=world -t $target -f $BUDFILES/base-with-labels/Containerfile2
run_buildah build $WITH_POLICY_JSON --inherit-annotations=false --created-annotation=false --annotation hello=world -t $target -f $BUDFILES/base-with-labels/Containerfile2
# no annotations should be inherited from base image
want_output='map["hello":"world"]'
run_buildah inspect --format '{{printf "%q" .ImageAnnotations}}' $target
@ -212,7 +212,7 @@ _EOF
cmp ${TEST_SCRATCH_DIR}/iid1 ${TEST_SCRATCH_DIR}/iid2
## Since we are inheriting no annotations, this should not use previous image present in the cache.
run_buildah build $WITH_POLICY_JSON --layers --inherit-annotations=false -t $target --from $base $BUDFILES/base-with-labels
run_buildah build $WITH_POLICY_JSON --layers --inherit-annotations=false --created-annotation=false -t $target --from $base $BUDFILES/base-with-labels
## should not contain `Using Cache`
assert "$output" !~ "Using cache"
@ -222,7 +222,7 @@ _EOF
expect_output "$want_output"
## Build again but set a new annotation and don't inherit annotations from base image
run_buildah build $WITH_POLICY_JSON --layers --inherit-annotations=false --annotation hello=world -t $target --from $base $BUDFILES/base-with-labels
run_buildah build $WITH_POLICY_JSON --layers --inherit-annotations=false --created-annotation=false --annotation hello=world -t $target --from $base $BUDFILES/base-with-labels
# no annotations should be inherited from base image
want_output='map["hello":"world"]'
@ -240,7 +240,7 @@ _EOF
cmp ${TEST_SCRATCH_DIR}/iid1 ${TEST_SCRATCH_DIR}/iid2
## Since we are inheriting no annotations, this should not use previous image present in the cache.
run_buildah build $WITH_POLICY_JSON --layers --inherit-annotations=false -t $target -f $BUDFILES/base-with-labels/Containerfile2
run_buildah build $WITH_POLICY_JSON --layers --inherit-annotations=false --created-annotation=false -t $target -f $BUDFILES/base-with-labels/Containerfile2
## but this time since 1st instruction is cached it should still use cache for some part, only annotations should not be there
expect_output --substring " Using cache"
@ -250,7 +250,7 @@ _EOF
expect_output "$want_output"
## Build again but set a new annotation and don't inherit annotations from base image
run_buildah build $WITH_POLICY_JSON --layers --inherit-annotations=false --annotation hello=world -t $target -f $BUDFILES/base-with-labels/Containerfile2
run_buildah build $WITH_POLICY_JSON --layers --inherit-annotations=false --created-annotation=false --annotation hello=world -t $target -f $BUDFILES/base-with-labels/Containerfile2
## but this time since 1st instruction is cached it should still use cache for some part, only last instruction will be changed since we are adding
## annotations to it
expect_output --substring " Using cache"
@ -2837,7 +2837,7 @@ _EOF
assert "$output" != "$not_want_output" "expected some annotations to be set in base image $base"
annotations=$(buildah inspect --format '{{ range $key, $value := .ImageAnnotations }}{{ $key }} {{end}}' $base)
annotationflags="--annotation hello=world"
annotationflags="--annotation hello=world --unsetannotation=org.opencontainers.image.created"
for annotation in $annotations; do
if test $annotation != io.buildah.version ; then
annotationflags="$annotationflags --unsetannotation $annotation"
@ -2875,7 +2875,7 @@ _EOF
cmp ${TEST_SCRATCH_DIR}/iid1 ${TEST_SCRATCH_DIR}/iid2
annotations=$(buildah inspect --format '{{ range $key, $value := .ImageAnnotations }}{{ $key }} {{end}}' $base)
annotationflags="--annotation hello=world"
annotationflags="--annotation hello=world --unsetannotation=org.opencontainers.image.created"
for annotation in $annotations; do
if test $annotation != io.buildah.version ; then
annotationflags="$annotationflags --unsetannotation $annotation"
@ -2908,7 +2908,7 @@ _EOF
assert "$output" != "$not_want_output" "expected some annotations to be set in base image $base"
annotations=$(buildah inspect --format '{{ range $key, $value := .ImageAnnotations }}{{ $key }} {{end}}' $base)
annotationflags="--annotation hello=world"
annotationflags="--annotation hello=world --created-annotation=false"
for annotation in $annotations; do
if test $annotation != io.buildah.version ; then
annotationflags="$annotationflags --unsetannotation $annotation"
@ -8079,3 +8079,41 @@ EOF
assert $hostname = sandbox "expected the hostname to be the static value 'sandbox'"
done
}
@test "bud-sets-created-annotation" {
_prefetch busybox
mkdir ${TEST_SCRATCH_DIR}/buildcontext
cat > ${TEST_SCRATCH_DIR}/buildcontext/Dockerfile << _EOF
FROM busybox
RUN pwd
_EOF
for flagdir in default: timestamp:--timestamp=0 sde:--source-date-epoch=0 suppressed:--unsetannotation=org.opencontainers.image.created specific:--created-annotation=false explicit:--created-annotation=true ; do
local flag=${flagdir##*:}
local subdir=${flagdir%%:*}
run_buildah build --layers --no-cache $flag -t oci:${TEST_SCRATCH_DIR}/$subdir ${TEST_SCRATCH_DIR}/buildcontext
local manifest=${TEST_SCRATCH_DIR}/$subdir/$(oci_image_manifest ${TEST_SCRATCH_DIR}/$subdir)
run jq -r '.annotations["org.opencontainers.image.created"]' "$manifest"
assert $status -eq 0
echo "$output"
local manifestcreated="$output"
local config=${TEST_SCRATCH_DIR}/$subdir/$(oci_image_config ${TEST_SCRATCH_DIR}/$subdir)
run jq -r '.created' "$config"
assert $status -eq 0
echo "$output"
local configcreated="$output"
if [[ "$flag" =~ "=0" ]]; then
assert $manifestcreated = $configcreated "manifest and config disagree on the image's created-time"
assert $manifestcreated = "1970-01-01T00:00:00Z"
elif [[ "$flag" =~ "unsetannotation" ]]; then
assert $configcreated != ""
assert $manifestcreated = "null"
elif [[ "$flag" =~ "created-annotation=false" ]]; then
assert $configcreated != ""
assert $manifestcreated = "null"
else
assert $manifestcreated = $configcreated "manifest and config disagree on the image's created-time"
assert $manifestcreated != ""
assert $manifestcreated != "1970-01-01T00:00:00Z"
fi
done
}

View File

@ -580,3 +580,47 @@ load helpers
assert "$output" != "$timestamp" "unexpected datestamp on $file in layer"
done
}
@test "commit-sets-created-annotation" {
_prefetch busybox
run_buildah from -q busybox
local cid="$output"
for annotation in a=b c=d ; do
local subdir=${annotation%%=*}
run_buildah commit --annotation $annotation "$cid" oci:${TEST_SCRATCH_DIR}/$subdir
local manifest=${TEST_SCRATCH_DIR}/$subdir/$(oci_image_manifest ${TEST_SCRATCH_DIR}/$subdir)
run jq -r '.annotations["'$subdir'"]' "$manifest"
assert $status -eq 0
echo "$output"
assert "$output" = ${annotation##*=}
done
for flagdir in default: timestamp:--timestamp=0 sde:--source-date-epoch=0 suppressed:--unsetannotation=org.opencontainers.image.created specific:--created-annotation=false explicit:--created-annotation=true ; do
local flag=${flagdir##*:}
local subdir=${flagdir%%:*}
run_buildah commit $flag "$cid" oci:${TEST_SCRATCH_DIR}/$subdir
local manifest=${TEST_SCRATCH_DIR}/$subdir/$(oci_image_manifest ${TEST_SCRATCH_DIR}/$subdir)
run jq -r '.annotations["org.opencontainers.image.created"]' "$manifest"
assert $status -eq 0
echo "$output"
local manifestcreated="$output"
local config=${TEST_SCRATCH_DIR}/$subdir/$(oci_image_config ${TEST_SCRATCH_DIR}/$subdir)
run jq -r '.created' "$config"
assert $status -eq 0
echo "$output"
local configcreated="$output"
if [[ "$flag" =~ "=0" ]]; then
assert $manifestcreated = $configcreated "manifest and config disagree on the image's created-time"
assert $manifestcreated = "1970-01-01T00:00:00Z"
elif [[ "$flag" =~ "unsetannotation" ]]; then
assert $configcreated != ""
assert $manifestcreated = "null"
elif [[ "$flag" =~ "created-annotation=false" ]]; then
assert $configcreated != ""
assert $manifestcreated = "null"
else
assert $manifestcreated = $configcreated "manifest and config disagree on the image's created-time"
assert $manifestcreated != ""
assert $manifestcreated != "1970-01-01T00:00:00Z"
fi
done
}

View File

@ -209,7 +209,7 @@ function check_matrix() {
$cid
run_buildah commit --format docker $WITH_POLICY_JSON $cid scratch-image-docker
run_buildah commit --format oci $WITH_POLICY_JSON $cid scratch-image-oci
run_buildah commit --created-annotation=false --format oci $WITH_POLICY_JSON $cid scratch-image-oci
run_buildah inspect --type=image --format '{{.ImageAnnotations}}' scratch-image-oci
expect_output "map[]"

View File

@ -914,6 +914,18 @@ function oci_image_manifest_digest() {
echo "$output"
}
########################
# oci_image_manifest #
########################
# prints the relative path of the manifest for the main image in an OCI
# layout in "$1"
function oci_image_manifest() {
local diff_id=$(oci_image_manifest_digest "$@")
local alg=${diff_id%%:*}
local val=${diff_id##*:}
echo blobs/"$alg"/"$val"
}
#############################
# oci_image_config_digest #
#############################