From 4764b0e403d7e63e438c94ca94a91e1b98704f51 Mon Sep 17 00:00:00 2001 From: Daniel J Walsh Date: Wed, 24 Sep 2025 14:04:27 -0400 Subject: [PATCH] Add creation timestamp to podman artifacts This commit implements automatic creation timestamp functionality for artifacts as requested in GitHub issue #27081, allowing users to see when artifacts were created. Changes made: - Add org.opencontainers.image.created annotation with Unix nanoseconds timestamp during artifact creation - Preserve original creation timestamp when using --append option - Update artifact inspect and add man pages to document the new functionality - Add comprehensive e2e and system BATS tests to verify creation timestamp behavior - Store timestamp as integer (Unix nanoseconds) for programmatic access The creation timestamp helps users understand artifact freshness, particularly useful for AI models and other time-sensitive artifacts managed by tools like RamaLama. Usage examples: podman artifact add myartifact:latest /path/to/file # Creates with timestamp podman artifact inspect myartifact:latest # Shows created annotation as integer podman artifact add --append myartifact:latest /file2 # Preserves original timestamp Fixes: https://github.com/containers/podman/issues/27081 Signed-off-by: Daniel J Walsh --- .../markdown/podman-artifact-add.1.md.in | 4 + .../markdown/podman-artifact-inspect.1.md | 7 +- pkg/libartifact/store/store.go | 13 ++- test/e2e/artifact_created_test.go | 95 +++++++++++++++++++ test/e2e/artifact_test.go | 36 +++++++ test/system/702-artifact-created.bats | 91 ++++++++++++++++++ 6 files changed, 243 insertions(+), 3 deletions(-) create mode 100644 test/e2e/artifact_created_test.go create mode 100644 test/system/702-artifact-created.bats diff --git a/docs/source/markdown/podman-artifact-add.1.md.in b/docs/source/markdown/podman-artifact-add.1.md.in index c5d4c08249..8306049585 100644 --- a/docs/source/markdown/podman-artifact-add.1.md.in +++ b/docs/source/markdown/podman-artifact-add.1.md.in @@ -12,6 +12,10 @@ Add an OCI artifact to the local store from the local filesystem. You must provide at least one file to create the artifact, but several can also be added. +Artifacts automatically include a creation timestamp in the +`org.opencontainers.image.created` annotation using RFC3339Nano format. When using +the `--append` option, the original creation timestamp is preserved. + ## OPTIONS diff --git a/docs/source/markdown/podman-artifact-inspect.1.md b/docs/source/markdown/podman-artifact-inspect.1.md index 6833fbfa7c..5d4cf47f8d 100644 --- a/docs/source/markdown/podman-artifact-inspect.1.md +++ b/docs/source/markdown/podman-artifact-inspect.1.md @@ -8,11 +8,16 @@ podman\-artifact\-inspect - Inspect an OCI artifact ## DESCRIPTION -Inspect an artifact in the local store. The artifact can be referred to with either: +Inspect an artifact in the local store and output the results in JSON format. +The artifact can be referred to with either: 1. Fully qualified artifact name 2. Full or partial digest of the artifact's manifest +The inspect output includes the artifact manifest with annotations. All artifacts +automatically include a creation timestamp in the `org.opencontainers.image.created` +annotation using RFC3339Nano format, showing when the artifact was initially created. + ## OPTIONS #### **--help** diff --git a/pkg/libartifact/store/store.go b/pkg/libartifact/store/store.go index a8fd0178a8..a9d1a0a651 100644 --- a/pkg/libartifact/store/store.go +++ b/pkg/libartifact/store/store.go @@ -253,13 +253,22 @@ func (as ArtifactStore) Add(ctx context.Context, dest string, artifactBlobs []en if err == nil { return nil, fmt.Errorf("%s: %w", dest, libartTypes.ErrArtifactAlreadyExists) } + + // Set creation timestamp and other annotations + annotations := make(map[string]string) + if options.Annotations != nil { + annotations = maps.Clone(options.Annotations) + } + annotations[specV1.AnnotationCreated] = time.Now().UTC().Format(time.RFC3339Nano) + artifactManifest = specV1.Manifest{ Versioned: specs.Versioned{SchemaVersion: ManifestSchemaVersion}, MediaType: specV1.MediaTypeImageManifest, ArtifactType: options.ArtifactMIMEType, // TODO This should probably be configurable once the CLI is capable - Config: specV1.DescriptorEmptyJSON, - Layers: make([]specV1.Descriptor, 0), + Config: specV1.DescriptorEmptyJSON, + Layers: make([]specV1.Descriptor, 0), + Annotations: annotations, } } else { artifact, _, err := artifacts.GetByNameOrDigest(dest) diff --git a/test/e2e/artifact_created_test.go b/test/e2e/artifact_created_test.go new file mode 100644 index 0000000000..8c17077668 --- /dev/null +++ b/test/e2e/artifact_created_test.go @@ -0,0 +1,95 @@ +//go:build linux || freebsd + +package integration + +import ( + "os" + "path/filepath" + "time" + + . "github.com/containers/podman/v5/test/utils" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Podman artifact created timestamp", func() { + + createArtifactFile := func(size int) (string, error) { + artifactFile := filepath.Join(podmanTest.TempDir, RandomString(12)) + f, err := os.Create(artifactFile) + if err != nil { + return "", err + } + defer f.Close() + + data := RandomString(size) + _, err = f.WriteString(data) + if err != nil { + return "", err + } + return artifactFile, nil + } + + It("podman artifact inspect shows created date in RFC3339 format", func() { + artifactFile, err := createArtifactFile(1024) + Expect(err).ToNot(HaveOccurred()) + + artifactName := "localhost/test/artifact-created" + + // Record time before creation (with some buffer for slow systems) + beforeCreate := time.Now().UTC().Add(-time.Second) + + // Add artifact + podmanTest.PodmanExitCleanly("artifact", "add", artifactName, artifactFile) + + // Record time after creation + afterCreate := time.Now().UTC().Add(time.Second) + + // Inspect artifact + a := podmanTest.InspectArtifact(artifactName) + Expect(a.Name).To(Equal(artifactName)) + + // Check that created annotation exists and is in valid RFC3339 format + createdStr, exists := a.Manifest.Annotations["org.opencontainers.image.created"] + Expect(exists).To(BeTrue(), "Should have org.opencontainers.image.created annotation") + + // Parse the created timestamp as RFC3339Nano + createdTime, err := time.Parse(time.RFC3339Nano, createdStr) + Expect(err).ToNot(HaveOccurred(), "Created timestamp should be valid RFC3339Nano format") + + // Verify timestamp is reasonable (within our time window) + Expect(createdTime).To(BeTemporally(">=", beforeCreate)) + Expect(createdTime).To(BeTemporally("<=", afterCreate)) + }) + + It("podman artifact append preserves original created date", func() { + artifactFile1, err := createArtifactFile(1024) + Expect(err).ToNot(HaveOccurred()) + artifactFile2, err := createArtifactFile(2048) + Expect(err).ToNot(HaveOccurred()) + + artifactName := "localhost/test/artifact-append" + + // Add initial artifact + podmanTest.PodmanExitCleanly("artifact", "add", artifactName, artifactFile1) + + // Get initial created timestamp + a := podmanTest.InspectArtifact(artifactName) + originalCreated := a.Manifest.Annotations["org.opencontainers.image.created"] + Expect(originalCreated).ToNot(BeEmpty()) + + // Wait a moment to ensure timestamps would be different + time.Sleep(100 * time.Millisecond) + + // Append to the artifact + podmanTest.PodmanExitCleanly("artifact", "add", "--append", artifactName, artifactFile2) + + // Check that created timestamp is unchanged + a = podmanTest.InspectArtifact(artifactName) + currentCreated := a.Manifest.Annotations["org.opencontainers.image.created"] + Expect(currentCreated).To(Equal(originalCreated), "Created timestamp should not change when appending") + + // Verify we have 2 layers + Expect(a.Manifest.Layers).To(HaveLen(2)) + }) +}) diff --git a/test/e2e/artifact_test.go b/test/e2e/artifact_test.go index 8b3a836271..18bb755a89 100644 --- a/test/e2e/artifact_test.go +++ b/test/e2e/artifact_test.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strconv" "strings" + "time" . "github.com/containers/podman/v5/test/utils" "github.com/containers/podman/v5/utils" @@ -573,6 +574,41 @@ var _ = Describe("Podman artifact", func() { failSession.WaitWithDefaultTimeout() Expect(failSession).Should(ExitWithError(125, "Error: append option is not compatible with type option")) }) + + It("podman artifact inspect shows created date", func() { + artifact1File, err := createArtifactFile(1024) + Expect(err).ToNot(HaveOccurred()) + artifact2File, err := createArtifactFile(2048) + Expect(err).ToNot(HaveOccurred()) + + artifact1Name := "localhost/test/artifact1" + + // Add artifact + podmanTest.PodmanExitCleanly("artifact", "add", artifact1Name, artifact1File) + + // Inspect artifact + a := podmanTest.InspectArtifact(artifact1Name) + Expect(a.Name).To(Equal(artifact1Name)) + + // Check that created annotation exists and is in valid Unix nanosecond format + createdStr, exists := a.Manifest.Annotations["org.opencontainers.image.created"] + Expect(exists).To(BeTrue(), "Should have org.opencontainers.image.created annotation") + + // podman artifact append preserves original created date + // Wait a moment to ensure timestamps would be different + time.Sleep(100 * time.Millisecond) + + // Append to the artifact + podmanTest.PodmanExitCleanly("artifact", "add", "--append", artifact1Name, artifact2File) + + // Check that created timestamp is unchanged + a = podmanTest.InspectArtifact(artifact1Name) + currentCreated := a.Manifest.Annotations["org.opencontainers.image.created"] + Expect(currentCreated).To(Equal(createdStr), "Created timestamp should not change when appending") + + // Verify we have 2 layers + Expect(a.Manifest.Layers).To(HaveLen(2)) + }) }) func digestToFilename(digest string) string { diff --git a/test/system/702-artifact-created.bats b/test/system/702-artifact-created.bats new file mode 100644 index 0000000000..ffa10d4f25 --- /dev/null +++ b/test/system/702-artifact-created.bats @@ -0,0 +1,91 @@ +#!/usr/bin/env bats -*- bats -*- +# +# Tests for podman artifact created date functionality +# + +load helpers + +# Create temporary artifact file for testing +function create_test_file() { + local content="$1" + local filename=$(random_string 12) + local filepath="$PODMAN_TMPDIR/$filename.txt" + echo "$content" > "$filepath" + echo "$filepath" +} + +function setup() { + basic_setup + skip_if_remote "artifacts are not remote" +} + +function teardown() { + run_podman artifact rm --all --ignore || true + basic_teardown +} + +@test "podman artifact inspect shows created date in RFC3339 format" { + local content="test content for created date" + local testfile1=$(create_test_file "$content") + local artifact_name="localhost/test/created-test" + local content2="appended content" + local testfile2=$(create_test_file "$content2") + + # Record time before creation (in seconds for comparison) + local before_epoch=$(date +%s) + + # Create artifact + run_podman artifact add $artifact_name "$testfile1" + + # Record time after creation (in seconds for comparison) + local after_epoch=$(date +%s) + after_epoch=$((after_epoch + 1)) + + # Inspect the artifact + run_podman artifact inspect $artifact_name + local output="$output" + + # Parse the JSON output to get the created annotation + local created_annotation + created_annotation=$(echo "$output" | jq -r '.Manifest.annotations["org.opencontainers.image.created"]') + + # Verify created annotation exists and is not null + assert "$created_annotation" != "null" "Should have org.opencontainers.image.created annotation" + assert "$created_annotation" != "" "Created annotation should not be empty" + + # Verify it's a valid RFC3339 timestamp by trying to parse it + # Convert to epoch for comparison + local created_epoch + created_epoch=$(date -d "$created_annotation" +%s 2>/dev/null) + + # Verify parsing succeeded + assert "$?" -eq 0 "Created timestamp should be valid RFC3339 format" + + # Verify timestamp is within reasonable bounds + assert "$created_epoch" -ge "$before_epoch" "Created time should be after before_epoch" + assert "$created_epoch" -le "$after_epoch" "Created time should be before after_epoch" + + # Wait a bit to ensure timestamps would differ if created new + sleep 1 + + # Append to artifact + run_podman artifact add --append $artifact_name "$testfile2" + + # Get the created timestamp after append + run_podman artifact inspect $artifact_name + local current_created + current_created=$(echo "$output" | jq -r '.Manifest.annotations["org.opencontainers.image.created"]') + + # Verify the created timestamp is preserved + assert "$current_created" = "$created_annotation" "Created timestamp should be preserved during append" + + # Verify we have 2 layers now + local layer_count + layer_count=$(echo "$output" | jq '.Manifest.layers | length') + assert "$layer_count" -eq 2 "Should have 2 layers after append" + + # Clean up + rm -f "$testfile1" "$testfile2" +} + +# vim: filetype=sh