From 94252cfda42154f36024c1e15a69b0b5679ce223 Mon Sep 17 00:00:00 2001 From: janfaron Date: Tue, 21 Apr 2026 15:18:17 +0200 Subject: [PATCH 1/2] [CU-86b9h9whq] Include chart metadata in pushed OCI manifest ## Summary Parse the uploaded chart with `helm.sh/helm/v3/pkg/chart/loader` and use its metadata when pushing: - Write `Chart.yaml` as JSON into the OCI config blob (was `{}`). - Set `title`, `description`, `version` annotations on the manifest (was empty). - Derive version tag and chart-name check from `Chart.yaml` instead of the filename. --- go.mod | 33 +++++++- go.sum | 92 +++++++++++++++++++- pkg/resource/out.go | 40 +++++---- pkg/resource/out_test.go | 175 ++++++++++++++++++++++++++++----------- 4 files changed, 274 insertions(+), 66 deletions(-) diff --git a/go.mod b/go.mod index 2d72bcc..663b391 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,42 @@ module github.com/cloudoperators/concourse-oci-helm-chart-resource -go 1.23.0 +go 1.25.0 require ( github.com/Masterminds/semver/v3 v3.4.0 github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/image-spec v1.1.1 github.com/pkg/errors v0.9.1 + helm.sh/helm/v3 v3.20.2 oras.land/oras-go/v2 v2.6.0 ) -require golang.org/x/sync v0.14.0 // indirect +require ( + github.com/cyphar/filepath-securejoin v0.6.1 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/text v0.33.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + k8s.io/api v0.35.1 // indirect + k8s.io/apiextensions-apiserver v0.35.1 // indirect + k8s.io/apimachinery v0.35.1 // indirect + k8s.io/client-go v0.35.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/go.sum b/go.sum index 9a4c162..8ec9b4f 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,100 @@ +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= +github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= -golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +helm.sh/helm/v3 v3.20.2 h1:binM4rvPx5DcNsa1sIt7UZi55lRbu3pZUFmQkSoRh48= +helm.sh/helm/v3 v3.20.2/go.mod h1:Fl1kBaWCpkUrM6IYXPjQ3bdZQfFrogKArqptvueZ6Ww= +k8s.io/api v0.35.1 h1:0PO/1FhlK/EQNVK5+txc4FuhQibV25VLSdLMmGpDE/Q= +k8s.io/api v0.35.1/go.mod h1:28uR9xlXWml9eT0uaGo6y71xK86JBELShLy4wR1XtxM= +k8s.io/apiextensions-apiserver v0.35.1 h1:p5vvALkknlOcAqARwjS20kJffgzHqwyQRM8vHLwgU7w= +k8s.io/apiextensions-apiserver v0.35.1/go.mod h1:2CN4fe1GZ3HMe4wBr25qXyJnJyZaquy4nNlNmb3R7AQ= +k8s.io/apimachinery v0.35.1 h1:yxO6gV555P1YV0SANtnTjXYfiivaTPvCTKX6w6qdDsU= +k8s.io/apimachinery v0.35.1/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/client-go v0.35.1 h1:+eSfZHwuo/I19PaSxqumjqZ9l5XiTEKbIaJ+j1wLcLM= +k8s.io/client-go v0.35.1/go.mod h1:1p1KxDt3a0ruRfc/pG4qT/3oHmUj1AhSHEcxNSGg+OA= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc= oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/pkg/resource/out.go b/pkg/resource/out.go index b443a3b..6f02f98 100644 --- a/pkg/resource/out.go +++ b/pkg/resource/out.go @@ -6,14 +6,15 @@ package resource import ( "bytes" "context" + "encoding/json" "fmt" "os" "path/filepath" - "strings" digest "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/pkg/errors" + "helm.sh/helm/v3/pkg/chart/loader" oras "oras.land/oras-go/v2" "oras.land/oras-go/v2/content/memory" ) @@ -54,22 +55,25 @@ func Put(ctx context.Context, request PutRequest, inputDir string, target oras.T return nil, fmt.Errorf("multiple .tgz files found in %s, expected exactly one", chartDir) } - chartPath := matches[0] - chartFilename := filepath.Base(chartPath) + chartContent, err := os.ReadFile(matches[0]) + if err != nil { + return nil, errors.Wrap(err, "failed to read chart file") + } - // Extract version tag from filename: -.tgz - prefix := request.Source.ChartName + "-" - if !strings.HasPrefix(chartFilename, prefix) { - return nil, fmt.Errorf("chart filename %q does not start with expected prefix %q", chartFilename, prefix) + loadedChart, err := loader.LoadArchive(bytes.NewReader(chartContent)) + if err != nil { + return nil, errors.Wrap(err, "failed to load chart archive") } - tag := strings.TrimSuffix(strings.TrimPrefix(chartFilename, prefix), ".tgz") - if tag == "" { - return nil, fmt.Errorf("could not extract version tag from filename %q", chartFilename) + + if loadedChart.Metadata.Name != request.Source.ChartName { + return nil, fmt.Errorf("chart name %q in archive does not match source chart_name %q", + loadedChart.Metadata.Name, request.Source.ChartName) } + tag := loadedChart.Metadata.Version - chartContent, err := os.ReadFile(chartPath) + configContent, err := json.Marshal(loadedChart.Metadata) if err != nil { - return nil, errors.Wrap(err, "failed to read chart file") + return nil, errors.Wrap(err, "failed to marshal chart metadata") } fmt.Fprintf(os.Stderr, "pushing %s version %s to %s\n", request.Source.ChartName, tag, request.Source.String()) @@ -86,8 +90,7 @@ func Put(ctx context.Context, request PutRequest, inputDir string, target oras.T return nil, errors.Wrap(err, "failed to push chart layer to store") } - // Push empty helm chart config - configContent := []byte("{}") + // Push helm chart config blob (Chart.yaml serialized as JSON) configDesc := ocispec.Descriptor{ MediaType: request.Source.GetConfigMediaType(), Digest: digest.FromBytes(configContent), @@ -97,10 +100,17 @@ func Put(ctx context.Context, request PutRequest, inputDir string, target oras.T return nil, errors.Wrap(err, "failed to push config to store") } - // Pack OCI manifest + // Pack OCI manifest with annotations that `helm push` would set, so + // consumers can resolve chart name/description/version without fetching + // and unpacking the chart archive. packOpts := oras.PackManifestOptions{ Layers: []ocispec.Descriptor{chartDesc}, ConfigDescriptor: &configDesc, + ManifestAnnotations: map[string]string{ + ocispec.AnnotationTitle: loadedChart.Metadata.Name, + ocispec.AnnotationDescription: loadedChart.Metadata.Description, + ocispec.AnnotationVersion: loadedChart.Metadata.Version, + }, } manifestDesc, err := oras.PackManifest(ctx, store, oras.PackManifestVersion1_1, "", packOpts) if err != nil { diff --git a/pkg/resource/out_test.go b/pkg/resource/out_test.go index acdf69e..c05ecde 100644 --- a/pkg/resource/out_test.go +++ b/pkg/resource/out_test.go @@ -10,9 +10,48 @@ import ( "testing" ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chartutil" + "oras.land/oras-go/v2" "oras.land/oras-go/v2/content/memory" ) +// saveTestChart writes -.tgz into dir using the Helm SDK, so +// tests exercise Put against a real helm archive rather than synthetic bytes. +func saveTestChart(t *testing.T, dir, name, description, version string) { + t.Helper() + if _, err := chartutil.Save(&chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: "v2", + Name: name, + Description: description, + Version: version, + }, + }, dir); err != nil { + t.Fatalf("save chart: %v", err) + } +} + +// fetchManifest resolves tag in target and decodes the manifest blob. +func fetchManifest(t *testing.T, target oras.ReadOnlyTarget, tag string) ocispec.Manifest { + t.Helper() + ctx := context.Background() + desc, err := target.Resolve(ctx, tag) + if err != nil { + t.Fatalf("resolve tag %q: %v", tag, err) + } + rc, err := target.Fetch(ctx, desc) + if err != nil { + t.Fatalf("fetch manifest: %v", err) + } + defer rc.Close() + var m ocispec.Manifest + if err := json.NewDecoder(rc).Decode(&m); err != nil { + t.Fatalf("decode manifest: %v", err) + } + return m +} + func TestPutRequestValidate(t *testing.T) { t.Run("putRequest should fail validation when chart_dir is missing", func(t *testing.T) { req := PutRequest{ @@ -58,10 +97,7 @@ func TestPut(t *testing.T) { if err := os.MkdirAll(chartDir, 0o755); err != nil { t.Fatal(err) } - chartContent := []byte("fake-chart-archive") - if err := os.WriteFile(filepath.Join(chartDir, "mychart-2.1.0.tgz"), chartContent, 0o644); err != nil { - t.Fatal(err) - } + saveTestChart(t, chartDir, "mychart", "A test chart", "2.1.0") target := memory.New() req := PutRequest{Source: source, Params: PutParams{ChartDir: "output"}} @@ -77,7 +113,6 @@ func TestPut(t *testing.T) { t.Error("expected non-empty digest") } - // Verify metadata foundChart, foundVersion := false, false for _, m := range resp.Metadata { if m.Name == "chart" && m.Value == "mychart" { @@ -94,7 +129,6 @@ func TestPut(t *testing.T) { t.Error("expected metadata with version=2.1.0") } - // Verify chart was pushed to target by resolving the tag desc, err := target.Resolve(context.Background(), "2.1.0") if err != nil { t.Fatalf("failed to resolve tag in target store: %v", err) @@ -113,8 +147,7 @@ func TestPut(t *testing.T) { target := memory.New() req := PutRequest{Source: source, Params: PutParams{ChartDir: "output"}} - _, err := Put(context.Background(), req, inputDir, target) - if err == nil { + if _, err := Put(context.Background(), req, inputDir, target); err == nil { t.Fatal("expected error for empty chart dir, got nil") } }) @@ -134,27 +167,40 @@ func TestPut(t *testing.T) { target := memory.New() req := PutRequest{Source: source, Params: PutParams{ChartDir: "output"}} - _, err := Put(context.Background(), req, inputDir, target) - if err == nil { + if _, err := Put(context.Background(), req, inputDir, target); err == nil { t.Fatal("expected error for multiple tgz files, got nil") } }) - t.Run("put should return error when tgz filename does not match chart name", func(t *testing.T) { + t.Run("put should return error when chart name in archive does not match source", func(t *testing.T) { + inputDir := t.TempDir() + chartDir := filepath.Join(inputDir, "output") + if err := os.MkdirAll(chartDir, 0o755); err != nil { + t.Fatal(err) + } + saveTestChart(t, chartDir, "otherchart", "A test chart", "1.0.0") + + target := memory.New() + req := PutRequest{Source: source, Params: PutParams{ChartDir: "output"}} + if _, err := Put(context.Background(), req, inputDir, target); err == nil { + t.Fatal("expected error for chart-name mismatch, got nil") + } + }) + + t.Run("put should return error when tgz is not a valid helm chart", func(t *testing.T) { inputDir := t.TempDir() chartDir := filepath.Join(inputDir, "output") if err := os.MkdirAll(chartDir, 0o755); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(chartDir, "otherchart-1.0.0.tgz"), []byte("x"), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(chartDir, "mychart-1.0.0.tgz"), []byte("not a helm chart"), 0o644); err != nil { t.Fatal(err) } target := memory.New() req := PutRequest{Source: source, Params: PutParams{ChartDir: "output"}} - _, err := Put(context.Background(), req, inputDir, target) - if err == nil { - t.Fatal("expected error for wrong filename prefix, got nil") + if _, err := Put(context.Background(), req, inputDir, target); err == nil { + t.Fatal("expected error for non-helm tgz, got nil") } }) @@ -164,9 +210,28 @@ func TestPut(t *testing.T) { if err := os.MkdirAll(chartDir, 0o755); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(chartDir, "mychart-1.0.0.tgz"), []byte("chart"), 0o644); err != nil { + saveTestChart(t, chartDir, "mychart", "A test chart", "1.0.0") + + target := memory.New() + req := PutRequest{Source: source, Params: PutParams{ChartDir: "output"}} + resp, err := Put(context.Background(), req, inputDir, target) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + m := fetchManifest(t, target, resp.Version.Tag) + if want := "application/vnd.cncf.helm.config.v1+json"; m.Config.MediaType != want { + t.Errorf("config mediatype: got %q, want %q", m.Config.MediaType, want) + } + }) + + t.Run("put should set OCI manifest annotations from Chart.yaml", func(t *testing.T) { + inputDir := t.TempDir() + chartDir := filepath.Join(inputDir, "output") + if err := os.MkdirAll(chartDir, 0o755); err != nil { t.Fatal(err) } + saveTestChart(t, chartDir, "mychart", "A test chart", "1.0.0") target := memory.New() req := PutRequest{Source: source, Params: PutParams{ChartDir: "output"}} @@ -175,23 +240,55 @@ func TestPut(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - manifestDesc, err := target.Resolve(context.Background(), resp.Version.Tag) + m := fetchManifest(t, target, resp.Version.Tag) + want := map[string]string{ + ocispec.AnnotationTitle: "mychart", + ocispec.AnnotationDescription: "A test chart", + ocispec.AnnotationVersion: "1.0.0", + } + for k, v := range want { + if got := m.Annotations[k]; got != v { + t.Errorf("annotation %q: got %q, want %q", k, got, v) + } + } + }) + + t.Run("put should embed chart metadata in config blob", func(t *testing.T) { + inputDir := t.TempDir() + chartDir := filepath.Join(inputDir, "output") + if err := os.MkdirAll(chartDir, 0o755); err != nil { + t.Fatal(err) + } + saveTestChart(t, chartDir, "mychart", "A test chart", "1.0.0") + + target := memory.New() + req := PutRequest{Source: source, Params: PutParams{ChartDir: "output"}} + resp, err := Put(context.Background(), req, inputDir, target) if err != nil { - t.Fatalf("failed to resolve tag: %v", err) + t.Fatalf("unexpected error: %v", err) + } + + m := fetchManifest(t, target, resp.Version.Tag) + // A literal "{}" (size 2) means the push is lossy and consumers can't + // recover name/version from the manifest alone. + if m.Config.Size <= 2 { + t.Errorf("config blob size is %d; expected chart metadata JSON, not empty {}", m.Config.Size) } - rc, err := target.Fetch(context.Background(), manifestDesc) + + rc, err := target.Fetch(context.Background(), m.Config) if err != nil { - t.Fatalf("failed to fetch manifest: %v", err) + t.Fatalf("failed to fetch config blob: %v", err) } defer rc.Close() - var manifest ocispec.Manifest - if err := json.NewDecoder(rc).Decode(&manifest); err != nil { - t.Fatalf("failed to decode manifest: %v", err) + var cfg map[string]any + if err := json.NewDecoder(rc).Decode(&cfg); err != nil { + t.Fatalf("failed to decode config blob as JSON: %v", err) } - - expected := "application/vnd.cncf.helm.config.v1+json" - if manifest.Config.MediaType != expected { - t.Errorf("expected config mediatype %q, got %q", expected, manifest.Config.MediaType) + if cfg["name"] != "mychart" { + t.Errorf("config.name: got %v, want %q", cfg["name"], "mychart") + } + if cfg["version"] != "1.0.0" { + t.Errorf("config.version: got %v, want %q", cfg["version"], "1.0.0") } }) @@ -201,9 +298,7 @@ func TestPut(t *testing.T) { if err := os.MkdirAll(chartDir, 0o755); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(chartDir, "mychart-1.0.0.tgz"), []byte("chart"), 0o644); err != nil { - t.Fatal(err) - } + saveTestChart(t, chartDir, "mychart", "A test chart", "1.0.0") customSource := Source{ Registry: "registry.example.com", @@ -219,23 +314,9 @@ func TestPut(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - manifestDesc, err := target.Resolve(context.Background(), resp.Version.Tag) - if err != nil { - t.Fatalf("failed to resolve tag: %v", err) - } - rc, err := target.Fetch(context.Background(), manifestDesc) - if err != nil { - t.Fatalf("failed to fetch manifest: %v", err) - } - defer rc.Close() - var manifest ocispec.Manifest - if err := json.NewDecoder(rc).Decode(&manifest); err != nil { - t.Fatalf("failed to decode manifest: %v", err) - } - - expected := "application/vnd.cncf.helm.chart.v2+json" - if manifest.Config.MediaType != expected { - t.Errorf("expected config mediatype %q, got %q", expected, manifest.Config.MediaType) + m := fetchManifest(t, target, resp.Version.Tag) + if want := "application/vnd.cncf.helm.chart.v2+json"; m.Config.MediaType != want { + t.Errorf("config mediatype: got %q, want %q", m.Config.MediaType, want) } }) } From d9defd0812052611aa1f37cd1e1a7a868201eb9a Mon Sep 17 00:00:00 2001 From: janfaron Date: Tue, 21 Apr 2026 16:56:13 +0200 Subject: [PATCH 2/2] [CU-86b9h9whq] fix lint issue --- pkg/resource/out_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/resource/out_test.go b/pkg/resource/out_test.go index c05ecde..f2c88ac 100644 --- a/pkg/resource/out_test.go +++ b/pkg/resource/out_test.go @@ -231,7 +231,7 @@ func TestPut(t *testing.T) { if err := os.MkdirAll(chartDir, 0o755); err != nil { t.Fatal(err) } - saveTestChart(t, chartDir, "mychart", "A test chart", "1.0.0") + saveTestChart(t, chartDir, "mychart", "Annotations test chart", "1.0.0") target := memory.New() req := PutRequest{Source: source, Params: PutParams{ChartDir: "output"}} @@ -243,7 +243,7 @@ func TestPut(t *testing.T) { m := fetchManifest(t, target, resp.Version.Tag) want := map[string]string{ ocispec.AnnotationTitle: "mychart", - ocispec.AnnotationDescription: "A test chart", + ocispec.AnnotationDescription: "Annotations test chart", ocispec.AnnotationVersion: "1.0.0", } for k, v := range want {