From b266a499fbfdfc3e6cb8b80afbf8b886997a39ef Mon Sep 17 00:00:00 2001 From: Max Barkley Date: Fri, 20 Mar 2026 16:51:48 -0400 Subject: [PATCH 1/2] [CU-86b76fmp8] Implement Put for pushing Helm charts to OCI registries --- cmd/out/main.go | 15 ++- go.mod | 6 +- pkg/resource/in.go | 3 +- pkg/resource/out.go | 122 +++++++++++++++++++- pkg/resource/out_test.go | 241 +++++++++++++++++++++++++++++++++++++++ pkg/resource/types.go | 9 ++ 6 files changed, 383 insertions(+), 13 deletions(-) create mode 100644 pkg/resource/out_test.go diff --git a/cmd/out/main.go b/cmd/out/main.go index 3a5dd47..34466a4 100644 --- a/cmd/out/main.go +++ b/cmd/out/main.go @@ -18,20 +18,31 @@ func main() { decoder := json.NewDecoder(os.Stdin) if err := decoder.Decode(&req); err != nil { fmt.Fprintf(os.Stderr, "failed to unmarshal request: %s\n", err) + os.Exit(1) } if len(os.Args) < 2 { fmt.Fprintln(os.Stderr, "missing arguments") + os.Exit(1) } if err := req.Validate(); err != nil { fmt.Fprintf(os.Stderr, "invalid source configuration: %s\n", err) + os.Exit(1) } inputDir := os.Args[1] - response, err := resource.Put(context.Background(), req, inputDir) + ctx := context.Background() + repo, err := resource.NewRepositoryForSource(ctx, req.Source) if err != nil { - fmt.Fprintf(os.Stderr, "get failed: %s\n", err) + fmt.Fprintf(os.Stderr, "failed to create repository: %s\n", err) + os.Exit(1) + } + response, err := resource.Put(ctx, req, inputDir, repo) + if err != nil { + fmt.Fprintf(os.Stderr, "put failed: %s\n", err) + os.Exit(1) } if err := json.NewEncoder(os.Stdout).Encode(response); err != nil { fmt.Fprintf(os.Stderr, "failed to marshal response: %s\n", err) + os.Exit(1) } } diff --git a/go.mod b/go.mod index d98a2a2..2d72bcc 100644 --- a/go.mod +++ b/go.mod @@ -4,12 +4,10 @@ go 1.23.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 oras.land/oras-go/v2 v2.6.0 ) -require ( - github.com/opencontainers/go-digest v1.0.0 // indirect - golang.org/x/sync v0.14.0 // indirect -) +require golang.org/x/sync v0.14.0 // indirect diff --git a/pkg/resource/in.go b/pkg/resource/in.go index 126c4d2..7915c58 100644 --- a/pkg/resource/in.go +++ b/pkg/resource/in.go @@ -71,12 +71,13 @@ func Get(ctx context.Context, request GetRequest, outputDir string, repo Reposit } // Find different layers. + configMediaType := request.Source.GetConfigMediaType() for _, layer := range manifestDescriptor.Layers { var fileExtension string switch layer.MediaType { case mediaTypeHelmChartContentArchive: fileExtension = ".tgz" - case mediaTypeHelmChartJSON: + case mediaTypeHelmChartJSON, configMediaType: fileExtension = ".json" default: continue diff --git a/pkg/resource/out.go b/pkg/resource/out.go index 9fc7e7e..b443a3b 100644 --- a/pkg/resource/out.go +++ b/pkg/resource/out.go @@ -4,19 +4,129 @@ package resource import ( + "bytes" "context" - "errors" + "fmt" + "os" + "path/filepath" + "strings" + + digest "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/pkg/errors" + oras "oras.land/oras-go/v2" + "oras.land/oras-go/v2/content/memory" ) type ( - PutRequest struct{} - PutResponse struct{} + PutRequest struct { + Source Source `json:"source"` + Params PutParams `json:"params"` + } + + PutParams struct { + ChartDir string `json:"chart_dir"` + } + + PutResponse struct { + Version Version `json:"version"` + Metadata []MetadataItem `json:"metadata,omitempty"` + } ) func (pr *PutRequest) Validate() error { - return nil + if pr.Params.ChartDir == "" { + return errors.New("params.chart_dir is required") + } + return pr.Source.Validate() } -func Put(ctx context.Context, request PutRequest, inputDir string) (*PutResponse, error) { - return nil, errors.New("not implemented") +func Put(ctx context.Context, request PutRequest, inputDir string, target oras.Target) (*PutResponse, error) { + chartDir := filepath.Join(inputDir, request.Params.ChartDir) + matches, err := filepath.Glob(filepath.Join(chartDir, "*.tgz")) + if err != nil { + return nil, errors.Wrap(err, "failed to glob for chart packages") + } + if len(matches) == 0 { + return nil, fmt.Errorf("no .tgz files found in %s", chartDir) + } + if len(matches) > 1 { + return nil, fmt.Errorf("multiple .tgz files found in %s, expected exactly one", chartDir) + } + + chartPath := matches[0] + chartFilename := filepath.Base(chartPath) + + // 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) + } + tag := strings.TrimSuffix(strings.TrimPrefix(chartFilename, prefix), ".tgz") + if tag == "" { + return nil, fmt.Errorf("could not extract version tag from filename %q", chartFilename) + } + + chartContent, err := os.ReadFile(chartPath) + if err != nil { + return nil, errors.Wrap(err, "failed to read chart file") + } + + fmt.Fprintf(os.Stderr, "pushing %s version %s to %s\n", request.Source.ChartName, tag, request.Source.String()) + + store := memory.New() + + // Push chart layer + chartDesc := ocispec.Descriptor{ + MediaType: mediaTypeHelmChartContentArchive, + Digest: digest.FromBytes(chartContent), + Size: int64(len(chartContent)), + } + if err := store.Push(ctx, chartDesc, bytes.NewReader(chartContent)); err != nil { + return nil, errors.Wrap(err, "failed to push chart layer to store") + } + + // Push empty helm chart config + configContent := []byte("{}") + configDesc := ocispec.Descriptor{ + MediaType: request.Source.GetConfigMediaType(), + Digest: digest.FromBytes(configContent), + Size: int64(len(configContent)), + } + if err := store.Push(ctx, configDesc, bytes.NewReader(configContent)); err != nil { + return nil, errors.Wrap(err, "failed to push config to store") + } + + // Pack OCI manifest + packOpts := oras.PackManifestOptions{ + Layers: []ocispec.Descriptor{chartDesc}, + ConfigDescriptor: &configDesc, + } + manifestDesc, err := oras.PackManifest(ctx, store, oras.PackManifestVersion1_1, "", packOpts) + if err != nil { + return nil, errors.Wrap(err, "failed to pack manifest") + } + + if err := store.Tag(ctx, manifestDesc, tag); err != nil { + return nil, errors.Wrap(err, "failed to tag manifest") + } + + // Push to remote registry + desc, err := oras.Copy(ctx, store, tag, target, tag, oras.DefaultCopyOptions) + if err != nil { + return nil, errors.Wrapf(err, "failed to push chart %s:%s", request.Source.String(), tag) + } + + fmt.Fprintf(os.Stderr, "pushed %s:%s (digest: %s)\n", request.Source.String(), tag, desc.Digest.String()) + + return &PutResponse{ + Version: Version{ + Tag: tag, + Digest: desc.Digest.String(), + }, + Metadata: []MetadataItem{ + {Name: "chart", Value: request.Source.ChartName}, + {Name: "version", Value: tag}, + }, + }, nil } diff --git a/pkg/resource/out_test.go b/pkg/resource/out_test.go new file mode 100644 index 0000000..acdf69e --- /dev/null +++ b/pkg/resource/out_test.go @@ -0,0 +1,241 @@ +// SPDX-License-Identifier: Apache-2.0 + +package resource + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "oras.land/oras-go/v2/content/memory" +) + +func TestPutRequestValidate(t *testing.T) { + t.Run("putRequest should fail validation when chart_dir is missing", func(t *testing.T) { + req := PutRequest{ + Source: Source{Registry: "r.example.com", Repository: "repo", ChartName: "chart"}, + Params: PutParams{ChartDir: ""}, + } + if err := req.Validate(); err == nil { + t.Error("expected error for missing chart_dir, got nil") + } + }) + + t.Run("putRequest should fail validation when source fields are missing", func(t *testing.T) { + req := PutRequest{ + Source: Source{}, + Params: PutParams{ChartDir: "charts"}, + } + if err := req.Validate(); err == nil { + t.Error("expected error for missing source fields, got nil") + } + }) + + t.Run("putRequest should pass validation when all fields are provided", func(t *testing.T) { + req := PutRequest{ + Source: Source{Registry: "r.example.com", Repository: "repo", ChartName: "chart"}, + Params: PutParams{ChartDir: "charts"}, + } + if err := req.Validate(); err != nil { + t.Errorf("expected no error, got: %v", err) + } + }) +} + +func TestPut(t *testing.T) { + source := Source{ + Registry: "registry.example.com", + Repository: "charts", + ChartName: "mychart", + } + + t.Run("put should push chart and return version with metadata", func(t *testing.T) { + inputDir := t.TempDir() + chartDir := filepath.Join(inputDir, "output") + 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) + } + + 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) + } + + if resp.Version.Tag != "2.1.0" { + t.Errorf("expected tag %q, got %q", "2.1.0", resp.Version.Tag) + } + if resp.Version.Digest == "" { + t.Error("expected non-empty digest") + } + + // Verify metadata + foundChart, foundVersion := false, false + for _, m := range resp.Metadata { + if m.Name == "chart" && m.Value == "mychart" { + foundChart = true + } + if m.Name == "version" && m.Value == "2.1.0" { + foundVersion = true + } + } + if !foundChart { + t.Error("expected metadata with chart=mychart") + } + if !foundVersion { + 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) + } + if desc.Digest.String() != resp.Version.Digest { + t.Errorf("target digest %q != response digest %q", desc.Digest.String(), resp.Version.Digest) + } + }) + + t.Run("put should return error when no tgz files exist", func(t *testing.T) { + inputDir := t.TempDir() + chartDir := filepath.Join(inputDir, "output") + if err := os.MkdirAll(chartDir, 0o755); 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 empty chart dir, got nil") + } + }) + + t.Run("put should return error when multiple tgz files exist", 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, "mychart-1.0.0.tgz"), []byte("a"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(chartDir, "mychart-2.0.0.tgz"), []byte("b"), 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 multiple tgz files, got nil") + } + }) + + t.Run("put should return error when tgz filename does not match chart name", 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 { + 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") + } + }) + + t.Run("put should use helm-compatible config mediatype by default", 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, "mychart-1.0.0.tgz"), []byte("chart"), 0o644); err != nil { + t.Fatal(err) + } + + 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) + } + + 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.config.v1+json" + if manifest.Config.MediaType != expected { + t.Errorf("expected config mediatype %q, got %q", expected, manifest.Config.MediaType) + } + }) + + t.Run("put should use custom config mediatype when configured", 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, "mychart-1.0.0.tgz"), []byte("chart"), 0o644); err != nil { + t.Fatal(err) + } + + customSource := Source{ + Registry: "registry.example.com", + Repository: "charts", + ChartName: "mychart", + ConfigMediaType: "application/vnd.cncf.helm.chart.v2+json", + } + + target := memory.New() + req := PutRequest{Source: customSource, Params: PutParams{ChartDir: "output"}} + resp, err := Put(context.Background(), req, inputDir, target) + if err != nil { + 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) + } + }) +} diff --git a/pkg/resource/types.go b/pkg/resource/types.go index 2345def..37d7b60 100644 --- a/pkg/resource/types.go +++ b/pkg/resource/types.go @@ -15,6 +15,15 @@ type Source struct { AuthUsername string `json:"auth_username,omitempty"` AuthPassword string `json:"auth_password,omitempty"` + + ConfigMediaType string `json:"config_media_type,omitempty"` +} + +func (s *Source) GetConfigMediaType() string { + if s.ConfigMediaType != "" { + return s.ConfigMediaType + } + return "application/vnd.cncf.helm.config.v1+json" } func (s *Source) Validate() error { From ad90706aaa06484e0a1204d63dfb2940534c9eb6 Mon Sep 17 00:00:00 2001 From: Max Barkley Date: Mon, 23 Mar 2026 14:02:34 -0400 Subject: [PATCH 2/2] [CU-86b76fmp8] Support building docker image cross-architecture --- Dockerfile | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 7345e0f..2320155 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,12 @@ FROM --platform=${BUILDPLATFORM:-linux/amd64} golang:1.24 AS build +ARG TARGETOS=linux +ARG TARGETARCH=amd64 WORKDIR /concourse-oci-helm-chart-resource COPY . . -RUN make build +RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} make build -FROM --platform=${BUILDPLATFORM:-linux/amd64} alpine:3.22.0 AS run +FROM --platform=${TARGETPLATFORM:-linux/amd64} alpine:3.22.0 AS run # upgrade all installed packages to fix potential CVEs in advance RUN apk upgrade --no-cache --no-progress \