Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*.dylib
.idea
.bin
helm-charts-oci-proxy
# Test binary, built with `go test -c`
*.test

Expand Down
26 changes: 25 additions & 1 deletion internal/manifest/charts.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package manifest
import (
"bytes"
"context"
"crypto/sha256"
"fmt"
"github.com/container-registry/helm-charts-oci-proxy/internal/blobs/handler"
"github.com/container-registry/helm-charts-oci-proxy/internal/errors"
Expand All @@ -20,8 +21,31 @@ import (
"path/filepath"
"sigs.k8s.io/yaml"
"strings"
"time"
)

// getDeterministicTimestamp generates a deterministic timestamp based on chart metadata
// This ensures that the same chart version always produces the same timestamp,
// making OCI artifacts reproducible.
func getDeterministicTimestamp(chartVer *repo.ChartVersion) time.Time {
// If the chart version has a Created timestamp, use it
if !chartVer.Created.IsZero() {
return chartVer.Created
}

// Otherwise, derive a deterministic timestamp from chart name and version
// Use a fixed base timestamp and add deterministic offset based on chart metadata
baseTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)

// Create a hash from chart name and version for deterministic offset
hash := sha256.Sum256([]byte(chartVer.Name + "@" + chartVer.Version))

// Use first 4 bytes of hash to create offset in seconds (max ~136 years)
offset := int64(hash[0])<<24 | int64(hash[1])<<16 | int64(hash[2])<<8 | int64(hash[3])

return baseTime.Add(time.Duration(offset) * time.Second)
}

func (m *Manifests) prepareChart(ctx context.Context, repo string, reference string) *errors.RegError {
elem := strings.Split(repo, "/")

Expand Down Expand Up @@ -145,7 +169,7 @@ func (m *Manifests) prepareChart(ctx context.Context, repo string, reference str
return nil
}

dst := NewInternalDst(fmt.Sprintf("%s/%s", path, chartVer.Name), m.blobHandler.(handler.BlobPutHandler), m)
dst := NewInternalDstWithChartVer(fmt.Sprintf("%s/%s", path, chartVer.Name), m.blobHandler.(handler.BlobPutHandler), m, chartVer)
// push
if reference == "" {
err = oras.CopyGraph(ctx, memStore, dst, root, copyOptions.CopyGraphOptions)
Expand Down
39 changes: 37 additions & 2 deletions internal/manifest/dst.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,45 @@ package manifest

import (
"context"
"crypto/sha256"
"github.com/container-registry/helm-charts-oci-proxy/internal/blobs/handler"
"github.com/container-registry/helm-charts-oci-proxy/pkg/verify"
v1 "github.com/google/go-containerregistry/pkg/v1"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"helm.sh/helm/v3/pkg/repo"
"io"
"strings"
"time"
)

// getDeterministicTimestamp generates a deterministic timestamp based on chart metadata
// This ensures that the same chart version always produces the same timestamp,
// making OCI artifacts reproducible.
func (f *InternalDst) getDeterministicTimestamp() time.Time {
// If we have chart version information, use it
if f.chartVer != nil {
// If the chart version has a Created timestamp, use it
if !f.chartVer.Created.IsZero() {
return f.chartVer.Created
}

// Otherwise, derive a deterministic timestamp from chart name and version
// Use a fixed base timestamp and add deterministic offset based on chart metadata
baseTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)

// Create a hash from chart name and version for deterministic offset
hash := sha256.Sum256([]byte(f.chartVer.Name + "@" + f.chartVer.Version))

// Use first 4 bytes of hash to create offset in seconds (max ~136 years)
offset := int64(hash[0])<<24 | int64(hash[1])<<16 | int64(hash[2])<<8 | int64(hash[3])

return baseTime.Add(time.Duration(offset) * time.Second)
}

// Fallback to current time if no chart version available (backward compatibility)
return time.Now()
}

const (
ProxyRefAnnotationPrefix = "com.container-registry.proxy-ref-"
)
Expand All @@ -32,10 +62,15 @@ type InternalDst struct {
repo string
blobPutHandler handler.BlobPutHandler
manifests *Manifests
chartVer *repo.ChartVersion // Added to store chart version for deterministic timestamps
}

func NewInternalDst(repo string, blobPutHandler handler.BlobPutHandler, manifests *Manifests) *InternalDst {
return &InternalDst{repo: repo, blobPutHandler: blobPutHandler, manifests: manifests}
return &InternalDst{repo: repo, blobPutHandler: blobPutHandler, manifests: manifests, chartVer: nil}
}

func NewInternalDstWithChartVer(repo string, blobPutHandler handler.BlobPutHandler, manifests *Manifests, chartVer *repo.ChartVersion) *InternalDst {
return &InternalDst{repo: repo, blobPutHandler: blobPutHandler, manifests: manifests, chartVer: chartVer}
}

func (f *InternalDst) Tag(ctx context.Context, desc ocispec.Descriptor, reference string) error {
Expand Down Expand Up @@ -99,7 +134,7 @@ func (f *InternalDst) Push(ctx context.Context, expected ocispec.Descriptor, con
ContentType: expected.MediaType,
Blob: binary,
Refs: refs,
CreatedAt: time.Now(),
CreatedAt: f.getDeterministicTimestamp(),
})
}
//blob
Expand Down
94 changes: 94 additions & 0 deletions internal/manifest/fluxcd_scenario_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package manifest

import (
"context"
"crypto/sha256"
"encoding/hex"
"testing"
"time"

"github.com/container-registry/helm-charts-oci-proxy/internal/blobs/handler/mem"
"helm.sh/helm/v3/pkg/chart"
"helm.sh/helm/v3/pkg/repo"
)

// TestFluxCDScenario simulates the FluxCD issue where different checksums are generated
// for the same chart, causing unnecessary reconciliation events
func TestFluxCDScenario(t *testing.T) {
// Simulate the cert-manager chart mentioned in the issue
certManagerChart := &repo.ChartVersion{
Metadata: &chart.Metadata{
Name: "cert-manager",
Version: "1.13.3",
APIVersion: "v2",
},
URLs: []string{"cert-manager-1.13.3.tgz"},
Created: time.Date(2023, 10, 15, 14, 30, 0, 0, time.UTC),
Digest: "sha256:cert-manager-digest",
}

config := Config{
Debug: false,
CacheTTL: time.Hour,
}

blobHandler := mem.NewMemHandler()
cache := &mockCache{}
manifests := NewManifests(context.Background(), blobHandler, config, cache, &mockLogger{})

// Simulate the same chart being processed multiple times by FluxCD reconciliation
chartData := []byte("cert-manager chart content")

// Simulate multiple reconciliation cycles (like FluxCD would do)
var checksums []string
var timestamps []time.Time

for i := 0; i < 5; i++ {
// Each time FluxCD reconciles, it would process the chart again
dst := NewInternalDstWithChartVer("charts.jetstack.io/cert-manager", blobHandler, manifests, certManagerChart)

manifest := Manifest{
ContentType: "application/vnd.docker.distribution.manifest.v2+json",
Blob: chartData,
Refs: []string{"sha256:layer1"},
CreatedAt: dst.getDeterministicTimestamp(),
}

hash := sha256.Sum256(manifest.Blob)
checksum := "sha256:" + hex.EncodeToString(hash[:])

checksums = append(checksums, checksum)
timestamps = append(timestamps, manifest.CreatedAt)

t.Logf("Reconciliation cycle %d: checksum=%s, timestamp=%v", i+1, checksum, manifest.CreatedAt)
}

// Verify all checksums are identical (fix for FluxCD issue)
baseChecksum := checksums[0]
for i, checksum := range checksums {
if checksum != baseChecksum {
t.Errorf("Reconciliation cycle %d produced different checksum: expected %s, got %s", i+1, baseChecksum, checksum)
}
}

// Verify all timestamps are identical
baseTimestamp := timestamps[0]
for i, timestamp := range timestamps {
if !timestamp.Equal(baseTimestamp) {
t.Errorf("Reconciliation cycle %d produced different timestamp: expected %v, got %v", i+1, baseTimestamp, timestamp)
}
}

// Verify the timestamp matches the chart's Created timestamp
if !baseTimestamp.Equal(certManagerChart.Created) {
t.Errorf("Expected timestamp to match chart Created time: expected %v, got %v", certManagerChart.Created, baseTimestamp)
}

t.Logf("✅ FluxCD scenario test passed - all checksums identical: %s", baseChecksum)
t.Logf("✅ All timestamps deterministic: %v", baseTimestamp)

// This fixes the original issue where FluxCD would see events like:
// Normal ArtifactUpToDate artifact up-to-date with remote revision: '1.13.3@sha256:01670a198f036bb7b4806c70f28c81097a1c1ae993e6e7e9668ceea3c9800d69'
// Normal ArtifactUpToDate artifact up-to-date with remote revision: '1.13.3@sha256:2565063055a68c060dcd8754f5395bc48ebcf974799a9647d077f644bf29a584'
// (different checksums for the same chart version)
}
139 changes: 139 additions & 0 deletions internal/manifest/integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package manifest

import (
"context"
"crypto/sha256"
"encoding/hex"
"testing"
"time"

"github.com/container-registry/helm-charts-oci-proxy/internal/blobs/handler/mem"
"helm.sh/helm/v3/pkg/chart"
"helm.sh/helm/v3/pkg/repo"
)

// TestIngressNginxReproducibility tests that ingress-nginx chart produces reproducible artifacts
func TestIngressNginxReproducibility(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}

// Create a mock chart version similar to ingress-nginx
chartVer := &repo.ChartVersion{
Metadata: &chart.Metadata{
Name: "ingress-nginx",
Version: "4.8.3",
APIVersion: "v2",
},
URLs: []string{"ingress-nginx-4.8.3.tgz"},
Created: time.Date(2023, 11, 1, 10, 30, 0, 0, time.UTC), // Fixed timestamp
Digest: "sha256:example1234",
}

// Create mock manifests instance
config := Config{
Debug: false,
CacheTTL: time.Hour,
}

blobHandler := mem.NewMemHandler()
cache := &mockCache{}
manifests := NewManifests(context.Background(), blobHandler, config, cache, &mockLogger{})

// Simulate the same chart content being processed twice
testChartData := []byte("mock ingress-nginx chart data")

// Create dst instances with the same chart version
dst1 := NewInternalDstWithChartVer("kubernetes.github.io/ingress-nginx", blobHandler, manifests, chartVer)
dst2 := NewInternalDstWithChartVer("kubernetes.github.io/ingress-nginx", blobHandler, manifests, chartVer)

// Get deterministic timestamps
ts1 := dst1.getDeterministicTimestamp()
ts2 := dst2.getDeterministicTimestamp()

// Timestamps should be identical
if !ts1.Equal(ts2) {
t.Errorf("Expected identical timestamps, got %v and %v", ts1, ts2)
}

// Create manifests with identical data
manifest1 := Manifest{
ContentType: "application/vnd.docker.distribution.manifest.v2+json",
Blob: testChartData,
Refs: []string{"sha256:layer1", "sha256:layer2"},
CreatedAt: ts1,
}

manifest2 := Manifest{
ContentType: "application/vnd.docker.distribution.manifest.v2+json",
Blob: testChartData,
Refs: []string{"sha256:layer1", "sha256:layer2"},
CreatedAt: ts2,
}

// Calculate checksums
hash1 := sha256.Sum256(manifest1.Blob)
checksum1 := "sha256:" + hex.EncodeToString(hash1[:])

hash2 := sha256.Sum256(manifest2.Blob)
checksum2 := "sha256:" + hex.EncodeToString(hash2[:])

// Checksums should be identical
if checksum1 != checksum2 {
t.Errorf("Expected identical checksums for ingress-nginx chart, got %s and %s", checksum1, checksum2)
}

t.Logf("Ingress-nginx reproducible checksum: %s", checksum1)
t.Logf("Deterministic timestamp: %v", ts1)

// Verify the timestamp is using the chart's Created timestamp
expectedTimestamp := time.Date(2023, 11, 1, 10, 30, 0, 0, time.UTC)
if !ts1.Equal(expectedTimestamp) {
t.Errorf("Expected timestamp %v, got %v", expectedTimestamp, ts1)
}
}

// TestFallbackToDeterministicTimestamp tests the fallback logic when no Created timestamp is available
func TestFallbackToDeterministicTimestamp(t *testing.T) {
// Create a chart version without a Created timestamp
chartVer := &repo.ChartVersion{
Metadata: &chart.Metadata{
Name: "test-chart",
Version: "1.0.0",
APIVersion: "v2",
},
URLs: []string{"test-chart-1.0.0.tgz"},
// Created is zero value (no timestamp)
Digest: "sha256:abcd1234",
}

config := Config{
Debug: false,
CacheTTL: time.Hour,
}

blobHandler := mem.NewMemHandler()
cache := &mockCache{}
manifests := NewManifests(context.Background(), blobHandler, config, cache, &mockLogger{})

// Create dst instances
dst1 := NewInternalDstWithChartVer("example.com/test-chart", blobHandler, manifests, chartVer)
dst2 := NewInternalDstWithChartVer("example.com/test-chart", blobHandler, manifests, chartVer)

// Get deterministic timestamps
ts1 := dst1.getDeterministicTimestamp()
ts2 := dst2.getDeterministicTimestamp()

// Timestamps should be identical even without Created timestamp
if !ts1.Equal(ts2) {
t.Errorf("Expected identical fallback timestamps, got %v and %v", ts1, ts2)
}

// Verify it's not the current time (should be deterministic based on chart metadata)
now := time.Now()
if ts1.After(now.Add(-time.Minute)) && ts1.Before(now.Add(time.Minute)) {
t.Error("Timestamp appears to be current time rather than deterministic")
}

t.Logf("Deterministic fallback timestamp: %v", ts1)
}
Loading