diff --git a/cmd/cmrel/cmd/const.go b/cmd/cmrel/cmd/const.go index bc7ec695..351bf10b 100644 --- a/cmd/cmrel/cmd/const.go +++ b/cmd/cmrel/cmd/const.go @@ -21,3 +21,6 @@ package cmd // WARNING: cosign requires a different format for the key; this is the format required by the GCP API but not cosign (which needs "versions" instead of "cryptoKeyVersions") // WARNING: This key is *manually* copied to hack/push_and_sign_chart.sh - if you update this key, you must also update that script. const defaultKMSKey = "projects/cert-manager-release/locations/europe-west1/keyRings/cert-manager-release/cryptoKeys/cert-manager-release-signing-key/cryptoKeyVersions/1" + +// defaultHelmOCIRegistry is the default OCI registry to push Helm charts to +const defaultHelmOCIRegistry = "quay.io/jetstack/charts" diff --git a/cmd/cmrel/cmd/gcb_publish.go b/cmd/cmrel/cmd/gcb_publish.go index 69620841..8056d305 100644 --- a/cmd/cmrel/cmd/gcb_publish.go +++ b/cmd/cmrel/cmd/gcb_publish.go @@ -41,6 +41,7 @@ import ( "github.com/cert-manager/release/pkg/release/helm" "github.com/cert-manager/release/pkg/release/publish/registry" "github.com/cert-manager/release/pkg/release/validation" + "github.com/cert-manager/release/pkg/shell" "github.com/cert-manager/release/pkg/sign" "github.com/cert-manager/release/pkg/sign/cosign" ) @@ -112,6 +113,20 @@ type gcbPublishOptions struct { // CosignPath points to the location of the cosign binary CosignPath string + // PublishedHelmChartOCIRegistry is the OCI registry to push Helm charts to + PublishedHelmChartOCIRegistry string + + // HelmPath points to the location of the helm binary + HelmPath string + + // CranePath points to the location of the crane binary + CranePath string + + // Runner is the shell command runner used to invoke external binaries + // (helm, crane, cosign). Tests can substitute a fake runner to avoid + // actually shelling out (and, importantly, to avoid hitting the KMS API). + Runner shell.Runner + // manualActionLogger logs to a buffer and is used by publish actions to log any manual // actions that must be taken by the user even after a successful publish is completed. // Get the log contents with ManualActionText() @@ -122,7 +137,9 @@ type gcbPublishOptions struct { // NewGCBPublishOptions creates options and initializes loggers correctly func NewGCBPublishOptions() *gcbPublishOptions { - o := &gcbPublishOptions{} + o := &gcbPublishOptions{ + Runner: shell.Default, + } o.manualActionLogger = log.New(&o.manualActionBuffer, "* ", 0) @@ -188,6 +205,9 @@ func (o *gcbPublishOptions) AddFlags(fs *flag.FlagSet, markRequired func(string) fs.StringVar(&o.CosignPath, "cosign-path", "cosign", "Full path to the cosign binary. Defaults to searching in $PATH for a binary called 'cosign'") fs.StringVar(&o.SigningKMSKey, "signing-kms-key", defaultKMSKey, "Full name of the GCP KMS key to use for signing.") fs.BoolVar(&o.SkipSigning, "skip-signing", false, "Skip signing container images.") + fs.StringVar(&o.PublishedHelmChartOCIRegistry, "published-helm-chart-oci-registry", defaultHelmOCIRegistry, "The OCI registry to push Helm charts to.") + fs.StringVar(&o.HelmPath, "helm-path", "helm", "Full path to the helm binary. Defaults to searching in $PATH for a binary called 'helm'") + fs.StringVar(&o.CranePath, "crane-path", "crane", "Full path to the crane binary. Defaults to searching in $PATH for a binary called 'crane'") fs.StringSliceVar(&o.PublishActions, "publish-actions", []string{"*"}, fmt.Sprintf("Comma-separated list of actions to take, or '*' to do everything. Only meaningful if nomock is set. Operations are done in alphabetical order. Actions can be removed with a prefix of '-'. Options: %s", strings.Join(allPublishActionNames(), ", "))) } @@ -205,6 +225,9 @@ func (o *gcbPublishOptions) print() { log.Printf(" CosignPath: %q", o.CosignPath) log.Printf(" SkipSigning: %v", o.SkipSigning) log.Printf(" SigningKMSKey: %q", o.SigningKMSKey) + log.Printf(" PublishedHelmChartOCIRegistry: %q", o.PublishedHelmChartOCIRegistry) + log.Printf(" HelmPath: %q", o.HelmPath) + log.Printf(" CranePath: %q", o.CranePath) log.Printf(" PublishActions: %q", strings.Join(o.PublishActions, ",")) } @@ -255,9 +278,12 @@ func canonicalizeAndVerifyPublishActions(rawActions []string) ([]string, error) } var publishActionMap map[string]publishAction = map[string]publishAction{ - "helmchartpr": pushHelmChartPR, + "helmchartoci": pushHelmChartOCI, "githubrelease": pushGitHubRelease, "pushcontainerimages": pushContainerImages, + + // helmchartpr has been deprecated in favour of helmchartoci + // "helmchartpr": pushHelmChartPR, } func gcbPublishCmd(rootOpts *rootOptions) *cobra.Command { @@ -370,6 +396,127 @@ func runGCBPublish(rootOpts *rootOptions, o *gcbPublishOptions) error { return nil } +// chartSignOpts are the cosign sign flags we use for Helm charts. +// +// Why TlogUpload=false? +// This flag prevents us creating a tlog entry for the signature, which is +// usually a good thing to do. Unfortunately, as well as creating the tlog +// entry, cosign also attempts to verify the tlog entry, which is the issue +// we run into - our KMS key uses SHA-512 as the signature digest algorithm, +// but there's no option to specify the digest algorithm for the tlog entry, +// so verification fails. We solved this for "cosign verify" with a cosign PR[0] +// a while back, but this problem hasn't been solved for tlog verification. +// [0]: https://github.com/sigstore/cosign/pull/1071 +// +// As of cosign 3, --tlog-upload=false is deprecated and we'll eventually have +// to migrate to using "--signing-config". "--tlog-upload" is incompatible with +// "--use-signing-config=true". The default in cosign 2 is "--use-signing-config=false". +// The default in cosign 3 is "--use-signing-config=true", so we have to manually +// disable it here to keep the same behaviour. +// +// cosign 3 also changes the default for "--new-bundle-format" to true, so we +// have to disable that too to keep the same behaviour as cosign 2, until we're +// able to verify that everything works with the new bundle format. +var chartSignOpts = cosign.SignOptions{ + TlogUpload: false, + NewBundleFormat: false, + UseSigningConfig: false, +} + +// chartVerifyOpts are the cosign verify flags we use for Helm charts. See the +// chartSignOpts comment for why we ignore tlog. +var chartVerifyOpts = cosign.VerifyOptions{ + SignatureDigestAlgorithm: "sha512", + InsecureIgnoreTlog: true, +} + +func pushHelmChartOCI(ctx context.Context, o *gcbPublishOptions, rel *release.Unpacked) error { + log.Printf("Pushing Helm chart to OCI registry %q", o.PublishedHelmChartOCIRegistry) + + runner := o.Runner + if runner == nil { + runner = shell.Default + } + + // Verify tools are available + log.Printf("Verifying helm installation") + if err := runner(ctx, "", o.HelmPath, "version"); err != nil { + return fmt.Errorf("failed to verify helm installation: %w", err) + } + + log.Printf("Verifying crane installation") + if err := runner(ctx, "", o.CranePath, "version"); err != nil { + return fmt.Errorf("failed to verify crane installation: %w", err) + } + + if len(rel.Charts) == 0 { + return fmt.Errorf("no charts found in unpacked release") + } + + ociURL := fmt.Sprintf("oci://%s", o.PublishedHelmChartOCIRegistry) + + var parsedKey sign.GCPKMSKey + if !o.SkipSigning { + var err error + parsedKey, err = sign.NewGCPKMSKey(o.SigningKMSKey) + if err != nil { + return fmt.Errorf("failed to parse KMS key: %w", err) + } + } + + for _, chart := range rel.Charts { + if chart.ProvPath() == nil { + log.Printf("Warning: .prov file not found for chart %q in release %s - this should only happen for releases earlier than v1.6.0", chart.Name(), rel.ReleaseVersion) + } + + // Push chart to OCI registry (helm automatically pushes .prov if it exists) + log.Printf("Pushing chart %q to %s", chart.Name(), ociURL) + if err := helm.PushChartToOCI(ctx, runner, chart.Path(), ociURL, o.HelmPath); err != nil { + return fmt.Errorf("failed to push chart %q to OCI registry: %w", chart.Name(), err) + } + + if o.SkipSigning { + log.Printf("Skipping signing for chart %q as skip-signing is set", chart.Name()) + continue + } + + chartRef := fmt.Sprintf("%s/%s:%s", o.PublishedHelmChartOCIRegistry, chart.Name(), rel.ReleaseVersion) + log.Printf("Signing chart %s with cosign", chartRef) + if err := cosign.SignWithOptions(ctx, runner, o.CosignPath, chartRef, parsedKey, chartSignOpts); err != nil { + return fmt.Errorf("failed to sign chart %q: %w", chart.Name(), err) + } + + log.Printf("Verifying chart signature for %s", chartRef) + if err := cosign.VerifyWithOptions(ctx, runner, o.CosignPath, chartRef, parsedKey, chartVerifyOpts); err != nil { + return fmt.Errorf("failed to verify chart signature for %q: %w", chart.Name(), err) + } + + // Handle non-v-prefixed version if applicable + if strings.HasPrefix(rel.ReleaseVersion, "v") { + nonVVersion := strings.TrimPrefix(rel.ReleaseVersion, "v") + destRef := fmt.Sprintf("%s/%s:%s", o.PublishedHelmChartOCIRegistry, chart.Name(), nonVVersion) + log.Printf("Copying chart %q to non-v-prefixed version: %s", chart.Name(), destRef) + + if err := helm.CopyChartTag(ctx, runner, chartRef, destRef, o.CranePath); err != nil { + return fmt.Errorf("failed to copy chart tag for %q: %w", chart.Name(), err) + } + + log.Printf("Signing non-v-prefixed chart %s", destRef) + if err := cosign.SignWithOptions(ctx, runner, o.CosignPath, destRef, parsedKey, chartSignOpts); err != nil { + return fmt.Errorf("failed to sign non-v chart %q: %w", chart.Name(), err) + } + + log.Printf("Verifying non-v-prefixed chart signature for %s", destRef) + if err := cosign.VerifyWithOptions(ctx, runner, o.CosignPath, destRef, parsedKey, chartVerifyOpts); err != nil { + return fmt.Errorf("failed to verify non-v chart signature for %q: %w", chart.Name(), err) + } + } + } + + log.Printf("Successfully pushed and signed %d Helm chart(s) to OCI registry", len(rel.Charts)) + return nil +} + func pushHelmChartPR(ctx context.Context, o *gcbPublishOptions, rel *release.Unpacked) error { githubClient, err := o.GitHubClient(ctx) if err != nil { @@ -567,14 +714,14 @@ func pushContainerImages(ctx context.Context, o *gcbPublishOptions, rel *release time.Sleep(registryWaitTime) } - if err := signRegistryContent(ctx, o, pushedContent); err != nil { + if err := signOCIImages(ctx, o, pushedContent); err != nil { return fmt.Errorf("failed to sign images: %w", err) } return nil } -func signRegistryContent(ctx context.Context, o *gcbPublishOptions, allContentToSign []string) error { +func signOCIImages(ctx context.Context, o *gcbPublishOptions, allContentToSign []string) error { if o.SkipSigning { log.Println("Skipping signing container images / manifest lists as skip-signing is set") return nil diff --git a/cmd/cmrel/cmd/gcb_publish_oci_test.go b/cmd/cmrel/cmd/gcb_publish_oci_test.go new file mode 100644 index 00000000..2c0d1def --- /dev/null +++ b/cmd/cmrel/cmd/gcb_publish_oci_test.go @@ -0,0 +1,399 @@ +/* +Copyright 2021 The cert-manager Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "archive/tar" + "compress/gzip" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/cert-manager/release/pkg/release" + "github.com/cert-manager/release/pkg/release/manifests" + "github.com/cert-manager/release/pkg/shell" +) + +const ( + testKMSKey = "projects/test-project/locations/test-location/keyRings/test-ring/cryptoKeys/test-key/cryptoKeyVersions/1" + testKMSKeyCosign = "gcpkms://projects/test-project/locations/test-location/keyRings/test-ring/cryptoKeys/test-key/versions/1" + testOCIRegistry = "quay.io/jetstack/charts" + testHelmPath = "/go/bin/helm" + testCranePath = "/go/bin/crane" + testCosignPath = "/go/bin/cosign" + testReleaseVersion = "v1.99.0" + testChartName = "cert-manager" + testChartVersion = "v1.99.0" +) + +// shellCall captures a single invocation made through the injected runner. +type shellCall struct { + cmd string + args []string +} + +// recorder is a fake shell.Runner that records every invocation. Callers can +// queue per-call errors via errs (one entry per expected call, nil for +// success). Calls past the length of errs return nil. +type recorder struct { + calls []shellCall + errs []error +} + +func (r *recorder) run(_ context.Context, _ string, cmd string, args ...string) error { + idx := len(r.calls) + r.calls = append(r.calls, shellCall{cmd: cmd, args: append([]string(nil), args...)}) + if idx < len(r.errs) { + return r.errs[idx] + } + return nil +} + +func (r *recorder) Runner() shell.Runner { + return r.run +} + +// writeChartTgz creates a minimal Helm chart tgz at the given path. The chart +// always contains a cert-manager/Chart.yaml with the supplied name/version. If +// withProv is true a sibling .prov file is also created. +func writeChartTgz(t *testing.T, dir, chartName, chartVersion string, withProv bool) string { + t.Helper() + + chartPath := filepath.Join(dir, fmt.Sprintf("%s-%s.tgz", chartName, chartVersion)) + + f, err := os.Create(chartPath) + if err != nil { + t.Fatalf("create chart tgz: %v", err) + } + defer f.Close() + + gz := gzip.NewWriter(f) + defer gz.Close() + tw := tar.NewWriter(gz) + defer tw.Close() + + chartYaml := fmt.Sprintf("name: %s\nversion: %s\nappVersion: %s\napiVersion: v1\n", chartName, chartVersion, chartVersion) + if err := tw.WriteHeader(&tar.Header{ + Name: "cert-manager/Chart.yaml", + Mode: 0o644, + Size: int64(len(chartYaml)), + }); err != nil { + t.Fatalf("write tar header: %v", err) + } + if _, err := tw.Write([]byte(chartYaml)); err != nil { + t.Fatalf("write tar body: %v", err) + } + + if withProv { + if err := os.WriteFile(chartPath+".prov", []byte("fake-prov"), 0o644); err != nil { + t.Fatalf("write prov: %v", err) + } + } + + return chartPath +} + +// newTestRelease builds a release.Unpacked containing a single chart at the +// given version. The chart is written to a fresh tempdir owned by the test. +func newTestRelease(t *testing.T, releaseVersion string) *release.Unpacked { + t.Helper() + + dir := t.TempDir() + chartPath := writeChartTgz(t, dir, testChartName, testChartVersion, true) + + chart, err := manifests.NewChart(chartPath) + if err != nil { + t.Fatalf("load chart: %v", err) + } + + return &release.Unpacked{ + ReleaseName: "cert-manager-test", + ReleaseVersion: releaseVersion, + Charts: []manifests.Chart{*chart}, + } +} + +func newTestPublishOptions(runner shell.Runner) *gcbPublishOptions { + o := NewGCBPublishOptions() + o.PublishedHelmChartOCIRegistry = testOCIRegistry + o.HelmPath = testHelmPath + o.CranePath = testCranePath + o.CosignPath = testCosignPath + o.SigningKMSKey = testKMSKey + o.Runner = runner + return o +} + +// assertCallEqual fails the test if the recorded call doesn't match wantCmd/wantArgs. +func assertCallEqual(t *testing.T, idx int, call shellCall, wantCmd string, wantArgs []string) { + t.Helper() + if call.cmd != wantCmd { + t.Errorf("call %d: cmd = %q, want %q", idx, call.cmd, wantCmd) + } + if !reflect.DeepEqual(call.args, wantArgs) { + t.Errorf("call %d: args mismatch\n got: %v\nwant: %v", idx, call.args, wantArgs) + } +} + +func TestPushHelmChartOCI_VPrefixedVersion(t *testing.T) { + rec := &recorder{} + o := newTestPublishOptions(rec.Runner()) + rel := newTestRelease(t, testReleaseVersion) // "v1.99.0" + + if err := pushHelmChartOCI(context.Background(), o, rel); err != nil { + t.Fatalf("pushHelmChartOCI failed: %v", err) + } + + chartPath := rel.Charts[0].Path() + vRef := fmt.Sprintf("%s/cert-manager:%s", testOCIRegistry, testReleaseVersion) + nonVRef := fmt.Sprintf("%s/cert-manager:%s", testOCIRegistry, strings.TrimPrefix(testReleaseVersion, "v")) + ociURL := "oci://" + testOCIRegistry + + wantCalls := []shellCall{ + // 1. helm version preflight + {cmd: testHelmPath, args: []string{"version"}}, + // 2. crane version preflight + {cmd: testCranePath, args: []string{"version"}}, + // 3. helm push + {cmd: testHelmPath, args: []string{"push", chartPath, ociURL}}, + // 4. cosign sign v-prefixed + {cmd: testCosignPath, args: []string{ + "sign", + "--key", testKMSKeyCosign, + "--tlog-upload=false", + "--new-bundle-format=false", + "--use-signing-config=false", + vRef, + }}, + // 5. cosign verify v-prefixed + {cmd: testCosignPath, args: []string{ + "verify", + "--key", testKMSKeyCosign, + "--signature-digest-algorithm", "sha512", + "--insecure-ignore-tlog=true", + vRef, + }}, + // 6. crane copy to non-v tag + {cmd: testCranePath, args: []string{"copy", vRef, nonVRef}}, + // 7. cosign sign non-v + {cmd: testCosignPath, args: []string{ + "sign", + "--key", testKMSKeyCosign, + "--tlog-upload=false", + "--new-bundle-format=false", + "--use-signing-config=false", + nonVRef, + }}, + // 8. cosign verify non-v + {cmd: testCosignPath, args: []string{ + "verify", + "--key", testKMSKeyCosign, + "--signature-digest-algorithm", "sha512", + "--insecure-ignore-tlog=true", + nonVRef, + }}, + } + + if len(rec.calls) != len(wantCalls) { + t.Fatalf("got %d calls, want %d:\n got: %+v\nwant: %+v", len(rec.calls), len(wantCalls), rec.calls, wantCalls) + } + for i, want := range wantCalls { + assertCallEqual(t, i, rec.calls[i], want.cmd, want.args) + } +} + +func TestPushHelmChartOCI_NonVPrefixedVersion(t *testing.T) { + rec := &recorder{} + o := newTestPublishOptions(rec.Runner()) + rel := newTestRelease(t, "1.99.0") // no v prefix + + if err := pushHelmChartOCI(context.Background(), o, rel); err != nil { + t.Fatalf("pushHelmChartOCI failed: %v", err) + } + + // Without a v prefix we should NOT see a crane copy or a second sign/verify. + // Expect: helm version, crane version, helm push, cosign sign, cosign verify. + if got, want := len(rec.calls), 5; got != want { + t.Fatalf("got %d calls, want %d: %+v", got, want, rec.calls) + } + + for _, c := range rec.calls { + if c.cmd == testCranePath { + for _, a := range c.args { + if a == "copy" { + t.Errorf("did not expect crane copy for non-v-prefixed version, got: %+v", c) + } + } + } + } +} + +func TestPushHelmChartOCI_SkipSigning(t *testing.T) { + rec := &recorder{} + o := newTestPublishOptions(rec.Runner()) + o.SkipSigning = true + rel := newTestRelease(t, testReleaseVersion) + + if err := pushHelmChartOCI(context.Background(), o, rel); err != nil { + t.Fatalf("pushHelmChartOCI failed: %v", err) + } + + // Expect: helm version, crane version, helm push. No sign/verify/copy. + if got, want := len(rec.calls), 3; got != want { + t.Fatalf("got %d calls, want %d: %+v", got, want, rec.calls) + } + for _, c := range rec.calls { + if c.cmd == testCosignPath { + t.Errorf("expected no cosign calls when skip-signing, got: %+v", c) + } + } +} + +func TestPushHelmChartOCI_NoCharts(t *testing.T) { + rec := &recorder{} + o := newTestPublishOptions(rec.Runner()) + rel := &release.Unpacked{ + ReleaseName: "cert-manager-test", + ReleaseVersion: testReleaseVersion, + } + + err := pushHelmChartOCI(context.Background(), o, rel) + if err == nil { + t.Fatal("expected error when there are no charts, got nil") + } + if !strings.Contains(err.Error(), "no charts") { + t.Errorf("expected error about missing charts, got: %v", err) + } +} + +func TestPushHelmChartOCI_HelmPreflightFails(t *testing.T) { + rec := &recorder{errs: []error{errors.New("helm not found")}} + o := newTestPublishOptions(rec.Runner()) + rel := newTestRelease(t, testReleaseVersion) + + err := pushHelmChartOCI(context.Background(), o, rel) + if err == nil { + t.Fatal("expected error when helm preflight fails") + } + // We should fail immediately - just one call should have been made. + if len(rec.calls) != 1 { + t.Errorf("expected 1 call before short-circuiting, got %d: %+v", len(rec.calls), rec.calls) + } +} + +func TestPushHelmChartOCI_CranePreflightFails(t *testing.T) { + rec := &recorder{errs: []error{nil, errors.New("crane not found")}} + o := newTestPublishOptions(rec.Runner()) + rel := newTestRelease(t, testReleaseVersion) + + err := pushHelmChartOCI(context.Background(), o, rel) + if err == nil { + t.Fatal("expected error when crane preflight fails") + } + if len(rec.calls) != 2 { + t.Errorf("expected 2 calls before short-circuiting, got %d: %+v", len(rec.calls), rec.calls) + } +} + +func TestPushHelmChartOCI_PushFails(t *testing.T) { + // helm version OK, crane version OK, helm push fails. + rec := &recorder{errs: []error{nil, nil, errors.New("push failed")}} + o := newTestPublishOptions(rec.Runner()) + rel := newTestRelease(t, testReleaseVersion) + + err := pushHelmChartOCI(context.Background(), o, rel) + if err == nil { + t.Fatal("expected error when helm push fails") + } + if !strings.Contains(err.Error(), "push") { + t.Errorf("expected error mentioning push, got: %v", err) + } + // We should NOT proceed to cosign after a push failure. + for _, c := range rec.calls { + if c.cmd == testCosignPath { + t.Errorf("did not expect cosign calls after push failure, got: %+v", c) + } + } +} + +func TestPushHelmChartOCI_InvalidKMSKey(t *testing.T) { + rec := &recorder{} + o := newTestPublishOptions(rec.Runner()) + o.SigningKMSKey = "not-a-valid-key" + rel := newTestRelease(t, testReleaseVersion) + + err := pushHelmChartOCI(context.Background(), o, rel) + if err == nil { + t.Fatal("expected error for invalid KMS key, got nil") + } + // We should have failed before any cosign invocation. + for _, c := range rec.calls { + if c.cmd == testCosignPath { + t.Errorf("did not expect cosign to be invoked with an invalid KMS key, got: %+v", c) + } + } +} + +func TestPushHelmChartOCI_ChartWithoutProv(t *testing.T) { + dir := t.TempDir() + chartPath := writeChartTgz(t, dir, testChartName, testChartVersion, false) + chart, err := manifests.NewChart(chartPath) + if err != nil { + t.Fatalf("load chart: %v", err) + } + rel := &release.Unpacked{ + ReleaseName: "cert-manager-test", + ReleaseVersion: testReleaseVersion, + Charts: []manifests.Chart{*chart}, + } + if chart.ProvPath() != nil { + t.Fatalf("test setup: expected chart without prov, got prov path %q", *chart.ProvPath()) + } + + rec := &recorder{} + o := newTestPublishOptions(rec.Runner()) + + if err := pushHelmChartOCI(context.Background(), o, rel); err != nil { + t.Fatalf("pushHelmChartOCI failed: %v", err) + } + + // Same command count as the v-prefixed test: missing .prov is a warning, not a hard failure. + if got, want := len(rec.calls), 8; got != want { + t.Fatalf("got %d calls, want %d: %+v", got, want, rec.calls) + } +} + +func TestNewGCBPublishOptionsHasDefaultRunner(t *testing.T) { + o := NewGCBPublishOptions() + if o.Runner == nil { + t.Error("expected NewGCBPublishOptions to populate a default Runner so production callers don't need to set one") + } +} + +func TestHelmChartOCIIsRegisteredPublishAction(t *testing.T) { + if _, ok := publishActionMap["helmchartoci"]; !ok { + t.Error("expected 'helmchartoci' to be a registered publish action") + } + if _, ok := publishActionMap["helmchartpr"]; ok { + t.Error("expected 'helmchartpr' to no longer be registered after deprecation") + } +} diff --git a/cmd/cmrel/cmd/publish.go b/cmd/cmrel/cmd/publish.go index 8ee99dba..ddc06b0c 100644 --- a/cmd/cmrel/cmd/publish.go +++ b/cmd/cmrel/cmd/publish.go @@ -93,6 +93,9 @@ type publishOptions struct { // release will be published to. PublishedGitHubRepo string + // PublishedHelmChartOCIRegistry is the OCI registry to push Helm charts to + PublishedHelmChartOCIRegistry string + // PublishActions is a list of publishing actions which should be taken, // or else "*" - the default - to mean "all actions" PublishActions []string @@ -119,6 +122,7 @@ func (o *publishOptions) AddFlags(fs *flag.FlagSet, markRequired func(string)) { fs.StringVar(&o.PublishedHelmChartGitHubBranch, "published-helm-chart-github-branch", release.DefaultHelmChartGitHubBranch, "The name of the main branch in the GitHub repository for Helm charts.") fs.StringVar(&o.PublishedGitHubOrg, "published-github-org", release.DefaultGitHubOrg, "The org of the repository where the release wil be published to.") fs.StringVar(&o.PublishedGitHubRepo, "published-github-repo", release.DefaultGitHubRepo, "The repo name in the provided org where the release will be published to.") + fs.StringVar(&o.PublishedHelmChartOCIRegistry, "published-helm-chart-oci-registry", defaultHelmOCIRegistry, "The OCI registry to push Helm charts to.") fs.StringVar(&o.SigningKMSKey, "signing-kms-key", defaultKMSKey, "Full name of the GCP KMS key to use for signing.") fs.BoolVar(&o.SkipSigning, "skip-signing", false, "Skip signing container images.") fs.StringSliceVar(&o.PublishActions, "publish-actions", []string{"*"}, fmt.Sprintf("Comma-separated list of actions to take, or '*' to do everything. Only meaningful if nomock is set. Order of operations is preserved if given, or is alphabetical by default. Actions can be removed with a prefix of '-'. Options: %s", strings.Join(allPublishActionNames(), ", "))) @@ -137,6 +141,7 @@ func (o *publishOptions) print() { log.Printf(" PublishedHelmChartGitHubBranch: %q", o.PublishedHelmChartGitHubBranch) log.Printf(" PublishedGitHubOrg: %q", o.PublishedGitHubOrg) log.Printf(" PublishedGitHubRepo: %q", o.PublishedGitHubRepo) + log.Printf(" PublishedHelmChartOCIRegistry: %q", o.PublishedHelmChartOCIRegistry) log.Printf(" PublishActions: %q", strings.Join(o.PublishActions, ",")) } @@ -209,6 +214,7 @@ func runPublish(rootOpts *rootOptions, o *publishOptions) error { build.Substitutions["_PUBLISHED_HELM_CHART_GITHUB_OWNER"] = o.PublishedHelmChartGitHubOwner build.Substitutions["_PUBLISHED_HELM_CHART_GITHUB_REPO"] = o.PublishedHelmChartGitHubRepo build.Substitutions["_PUBLISHED_HELM_CHART_GITHUB_BRANCH"] = o.PublishedHelmChartGitHubBranch + build.Substitutions["_PUBLISHED_HELM_CHART_OCI_REGISTRY"] = o.PublishedHelmChartOCIRegistry build.Substitutions["_PUBLISHED_IMAGE_REPO"] = o.PublishedImageRepository build.Substitutions["_PUBLISH_ACTIONS"] = strings.Join(o.PublishActions, ",") build.Substitutions["_SKIP_SIGNING"] = fmt.Sprintf("%v", o.SkipSigning) diff --git a/gcb/publish/cloudbuild.yaml b/gcb/publish/cloudbuild.yaml index 7c886ae7..51a75ab3 100644 --- a/gcb/publish/cloudbuild.yaml +++ b/gcb/publish/cloudbuild.yaml @@ -1,5 +1,8 @@ timeout: 14400s +serviceAccount: projects/cert-manager-release/serviceAccounts/cert-manager-release-gcb@cert-manager-release.iam.gserviceaccount.com +logsBucket: gs://cert-manager-release-logs + #### SECURITY NOTICE #### # Google Cloud Build (GCB) supports the usage of secrets for build requests. # Secrets appear within GCB configs as base64-encoded strings. @@ -28,7 +31,22 @@ steps: entrypoint: go args: - install - - github.com/sigstore/cosign/cmd/cosign@${_COSIGN_REPO_REF} + - github.com/sigstore/cosign/v3/cmd/cosign@${_COSIGN_REPO_REF} + +- name: docker.io/library/golang:1.26-alpine@sha256:c2a1f7b2095d046ae14b286b18413a05bb82c9bca9b25fe7ff5efef0f0826166 + entrypoint: go + args: + - install + - github.com/google/go-containerregistry/cmd/crane@${_CRANE_REPO_REF} + +- name: docker.io/library/golang:1.26-alpine@sha256:c2a1f7b2095d046ae14b286b18413a05bb82c9bca9b25fe7ff5efef0f0826166 + entrypoint: sh + args: + - -c + - | + apk add --no-cache curl + curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | sh + mv /usr/local/bin/helm /go/bin/helm ## Write DOCKER_CONFIG file to $HOME/.docker/config.json - name: gcr.io/cloud-builders/docker:24.0.9@sha256:5cc860228c53bb63b37989e0d26921edf8992c9cca903ccee88f748e4a65b201 @@ -63,6 +81,9 @@ steps: - --signing-kms-key=${_KMS_KEY} - --skip-signing=${_SKIP_SIGNING} - --cosign-path=/go/bin/cosign + - --published-helm-chart-oci-registry=${_PUBLISHED_HELM_CHART_OCI_REGISTRY} + - --helm-path=/go/bin/helm + - --crane-path=/go/bin/crane tags: - "cert-manager-release-publish" @@ -90,6 +111,7 @@ substitutions: _PUBLISHED_HELM_CHART_GITHUB_OWNER: "" _PUBLISHED_HELM_CHART_GITHUB_REPO: "" _PUBLISHED_HELM_CHART_GITHUB_BRANCH: "" + _PUBLISHED_HELM_CHART_OCI_REGISTRY: "quay.io/jetstack/charts" _PUBLISHED_IMAGE_REPO: "" ## Used to control the exact artifacts which will be published _PUBLISH_ACTIONS: "*" @@ -98,7 +120,6 @@ substitutions: ## Ref for cert-manager/release repo to use when installing cmrel _RELEASE_REPO_REF: "master" ## Version of the cosign tool to install - _COSIGN_REPO_REF: "v1.13.6" - -serviceAccount: projects/cert-manager-release/serviceAccounts/cert-manager-release-gcb@cert-manager-release.iam.gserviceaccount.com -logsBucket: gs://cert-manager-release-logs + _COSIGN_REPO_REF: "v3.0.6" + ## Version of the crane tool to install + _CRANE_REPO_REF: "v0.21.5" diff --git a/pkg/release/helm/oci.go b/pkg/release/helm/oci.go new file mode 100644 index 00000000..bef33464 --- /dev/null +++ b/pkg/release/helm/oci.go @@ -0,0 +1,42 @@ +/* +Copyright 2021 The cert-manager Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package helm + +import ( + "context" + + "github.com/cert-manager/release/pkg/shell" +) + +// PushChartToOCI pushes a Helm chart to an OCI registry using the helm command. +// The helm command automatically pushes the .prov file if it exists alongside +// the chart. If runner is nil, the default real runner is used. +func PushChartToOCI(ctx context.Context, runner shell.Runner, chartPath, ociURL, helmPath string) error { + if runner == nil { + runner = shell.Default + } + return runner(ctx, "", helmPath, "push", chartPath, ociURL) +} + +// CopyChartTag copies a chart from one OCI tag to another using crane. +// If runner is nil, the default real runner is used. +func CopyChartTag(ctx context.Context, runner shell.Runner, sourceTag, destTag, cranePath string) error { + if runner == nil { + runner = shell.Default + } + return runner(ctx, "", cranePath, "copy", sourceTag, destTag) +} diff --git a/pkg/release/helm/oci_test.go b/pkg/release/helm/oci_test.go new file mode 100644 index 00000000..eac5d940 --- /dev/null +++ b/pkg/release/helm/oci_test.go @@ -0,0 +1,123 @@ +/* +Copyright 2021 The cert-manager Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package helm + +import ( + "context" + "errors" + "reflect" + "testing" +) + +type recordedCall struct { + workDir string + cmd string + args []string +} + +func newRecorder(err error) (*[]recordedCall, func(ctx context.Context, workDir string, cmd string, args ...string) error) { + calls := &[]recordedCall{} + runner := func(ctx context.Context, workDir string, cmd string, args ...string) error { + *calls = append(*calls, recordedCall{ + workDir: workDir, + cmd: cmd, + args: append([]string(nil), args...), + }) + return err + } + return calls, runner +} + +func TestPushChartToOCI(t *testing.T) { + calls, runner := newRecorder(nil) + + err := PushChartToOCI( + context.Background(), + runner, + "/tmp/cert-manager-v1.99.0.tgz", + "oci://quay.io/jetstack/charts", + "/usr/local/bin/helm", + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(*calls) != 1 { + t.Fatalf("expected 1 call, got %d", len(*calls)) + } + + call := (*calls)[0] + if call.cmd != "/usr/local/bin/helm" { + t.Errorf("expected helm path, got %q", call.cmd) + } + wantArgs := []string{"push", "/tmp/cert-manager-v1.99.0.tgz", "oci://quay.io/jetstack/charts"} + if !reflect.DeepEqual(call.args, wantArgs) { + t.Errorf("args mismatch\n got: %v\nwant: %v", call.args, wantArgs) + } +} + +func TestPushChartToOCIPropagatesError(t *testing.T) { + wantErr := errors.New("helm push failed") + _, runner := newRecorder(wantErr) + + err := PushChartToOCI(context.Background(), runner, "/x", "oci://r", "helm") + if !errors.Is(err, wantErr) { + t.Errorf("expected error to wrap %v, got %v", wantErr, err) + } +} + +func TestCopyChartTag(t *testing.T) { + calls, runner := newRecorder(nil) + + err := CopyChartTag( + context.Background(), + runner, + "quay.io/jetstack/charts/cert-manager:v1.99.0", + "quay.io/jetstack/charts/cert-manager:1.99.0", + "/usr/local/bin/crane", + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(*calls) != 1 { + t.Fatalf("expected 1 call, got %d", len(*calls)) + } + + call := (*calls)[0] + if call.cmd != "/usr/local/bin/crane" { + t.Errorf("expected crane path, got %q", call.cmd) + } + wantArgs := []string{ + "copy", + "quay.io/jetstack/charts/cert-manager:v1.99.0", + "quay.io/jetstack/charts/cert-manager:1.99.0", + } + if !reflect.DeepEqual(call.args, wantArgs) { + t.Errorf("args mismatch\n got: %v\nwant: %v", call.args, wantArgs) + } +} + +func TestCopyChartTagPropagatesError(t *testing.T) { + wantErr := errors.New("crane copy failed") + _, runner := newRecorder(wantErr) + + err := CopyChartTag(context.Background(), runner, "src", "dest", "crane") + if !errors.Is(err, wantErr) { + t.Errorf("expected error to wrap %v, got %v", wantErr, err) + } +} diff --git a/pkg/release/manifests/chart.go b/pkg/release/manifests/chart.go index 2e0ac503..bfe7c020 100644 --- a/pkg/release/manifests/chart.go +++ b/pkg/release/manifests/chart.go @@ -98,6 +98,10 @@ func (c *Chart) ProvPath() *string { return c.provPath } +func (c *Chart) Name() string { + return c.meta.Name +} + func (c *Chart) Version() string { return c.meta.Version } diff --git a/pkg/release/platforms.go b/pkg/release/platforms.go index b2b5d28d..917862e5 100644 --- a/pkg/release/platforms.go +++ b/pkg/release/platforms.go @@ -175,7 +175,14 @@ func IsClientOS(os string) bool { } // Cmctl is only shipped with v1.14.X and below. +// Returns false (rather than panicking) when releaseVersion is not a parseable +// semver — callers in the validation path can legitimately reach this with an +// already-invalid version they are about to report a violation for. func CmctlIsShipped(releaseVersion string) bool { releaseVersion, _ = strings.CutPrefix(releaseVersion, "v") - return semver.MustParse(releaseVersion).LT(semver.MustParse("1.15.0-alpha.0")) + parsed, err := semver.Parse(releaseVersion) + if err != nil { + return false + } + return parsed.LT(semver.MustParse("1.15.0-alpha.0")) } diff --git a/pkg/release/platforms_test.go b/pkg/release/platforms_test.go index 7c4d6a7d..fdaab61e 100644 --- a/pkg/release/platforms_test.go +++ b/pkg/release/platforms_test.go @@ -182,6 +182,13 @@ func TestCmctlIsShipped(t *testing.T) { {"1.15.0-beta.0", false}, {"1.15.0", false}, {"1.15.1", false}, + + // Invalid semver inputs should not panic - callers in the validation + // path can reach this with malformed versions they are about to flag + // as a violation, so we want a safe default rather than a crash. + {"", false}, + {"v0.15", false}, + {"not-a-version", false}, } for _, tt := range tests { t.Run(fmt.Sprintf("%s", tt.version), func(t *testing.T) { diff --git a/pkg/release/validation/validate.go b/pkg/release/validation/validate.go index 9574feb1..0fa11531 100644 --- a/pkg/release/validation/validate.go +++ b/pkg/release/validation/validate.go @@ -58,6 +58,9 @@ func ValidateUnpackedRelease(opts Options, rel *release.Unpacked) ([]string, err } func validateSemver(v string) error { + if v == "" { + return fmt.Errorf("version number is empty") + } if v[0] != 'v' { return fmt.Errorf("version number must have a leading 'v' character") } diff --git a/pkg/shell/shell.go b/pkg/shell/shell.go index c31f4f7e..9cf4482a 100644 --- a/pkg/shell/shell.go +++ b/pkg/shell/shell.go @@ -22,8 +22,13 @@ import ( "os/exec" ) -// Command runs the given command with the given args -func Command(ctx context.Context, workDir string, cmd string, args ...string) error { +// Runner runs a command. Tests can substitute fake implementations to record +// invocations without actually executing the binary. +type Runner func(ctx context.Context, workDir string, cmd string, args ...string) error + +// Default is the Runner that actually invokes the command via exec.CommandContext, +// streaming stdout/stderr to the process's standard streams. +var Default Runner = func(ctx context.Context, workDir string, cmd string, args ...string) error { c := exec.CommandContext(ctx, cmd, args...) // redirect all output @@ -35,3 +40,8 @@ func Command(ctx context.Context, workDir string, cmd string, args ...string) er return c.Run() } + +// Command runs the given command with the given args using the Default runner. +func Command(ctx context.Context, workDir string, cmd string, args ...string) error { + return Default(ctx, workDir, cmd, args...) +} diff --git a/pkg/sign/cosign/cosign.go b/pkg/sign/cosign/cosign.go index 5ea40332..ece8995f 100644 --- a/pkg/sign/cosign/cosign.go +++ b/pkg/sign/cosign/cosign.go @@ -18,6 +18,7 @@ package cosign import ( "context" + "fmt" "github.com/cert-manager/release/pkg/shell" "github.com/cert-manager/release/pkg/sign" @@ -38,3 +39,71 @@ func Sign(ctx context.Context, cosignPath string, containers []string, key sign. func Version(ctx context.Context, cosignPath string) error { return shell.Command(ctx, "", cosignPath, []string{"version"}...) } + +// SignOptions contains options for signing with cosign +type SignOptions struct { + TlogUpload bool + NewBundleFormat bool + UseSigningConfig bool +} + +// SignArgs builds the argument list for "cosign sign" with the given options. +// It is exported so callers (and tests) can inspect the exact CLI invocation +// without having to actually run cosign. +func SignArgs(container string, key sign.GCPKMSKey, opts SignOptions) []string { + return []string{ + "sign", + "--key", key.CosignFormat(), + fmt.Sprintf("--tlog-upload=%t", opts.TlogUpload), + fmt.Sprintf("--new-bundle-format=%t", opts.NewBundleFormat), + fmt.Sprintf("--use-signing-config=%t", opts.UseSigningConfig), + container, + } +} + +// SignWithOptions calls out to cosign to sign a container with specific options. +// The runner parameter allows tests to inject a fake. If runner is nil, the +// default real runner is used. +func SignWithOptions(ctx context.Context, runner shell.Runner, cosignPath string, container string, key sign.GCPKMSKey, opts SignOptions) error { + if runner == nil { + runner = shell.Default + } + return runner(ctx, "", cosignPath, SignArgs(container, key, opts)...) +} + +// VerifyOptions contains options for verifying with cosign +type VerifyOptions struct { + SignatureDigestAlgorithm string + InsecureIgnoreTlog bool +} + +// VerifyArgs builds the argument list for "cosign verify" with the given options. +// It is exported so callers (and tests) can inspect the exact CLI invocation +// without having to actually run cosign. +func VerifyArgs(container string, key sign.GCPKMSKey, opts VerifyOptions) []string { + args := []string{ + "verify", + "--key", key.CosignFormat(), + } + + if opts.SignatureDigestAlgorithm != "" { + args = append(args, "--signature-digest-algorithm", opts.SignatureDigestAlgorithm) + } + + if opts.InsecureIgnoreTlog { + args = append(args, "--insecure-ignore-tlog=true") + } + + args = append(args, container) + return args +} + +// VerifyWithOptions calls out to cosign to verify a container signature with specific options. +// The runner parameter allows tests to inject a fake. If runner is nil, the +// default real runner is used. +func VerifyWithOptions(ctx context.Context, runner shell.Runner, cosignPath string, container string, key sign.GCPKMSKey, opts VerifyOptions) error { + if runner == nil { + runner = shell.Default + } + return runner(ctx, "", cosignPath, VerifyArgs(container, key, opts)...) +} diff --git a/pkg/sign/cosign/cosign_test.go b/pkg/sign/cosign/cosign_test.go new file mode 100644 index 00000000..958ed62b --- /dev/null +++ b/pkg/sign/cosign/cosign_test.go @@ -0,0 +1,226 @@ +/* +Copyright 2021 The cert-manager Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cosign + +import ( + "context" + "errors" + "reflect" + "testing" + + "github.com/cert-manager/release/pkg/sign" +) + +const ( + testKeyRaw = "projects/test-project/locations/test-location/keyRings/test-ring/cryptoKeys/test-key/cryptoKeyVersions/1" + testKeyCosignValue = "gcpkms://projects/test-project/locations/test-location/keyRings/test-ring/cryptoKeys/test-key/versions/1" + testContainer = "quay.io/jetstack/charts/cert-manager:v1.99.0" +) + +func mustKey(t *testing.T) sign.GCPKMSKey { + t.Helper() + k, err := sign.NewGCPKMSKey(testKeyRaw) + if err != nil { + t.Fatalf("failed to parse test key: %v", err) + } + return k +} + +func TestSignArgs(t *testing.T) { + key := mustKey(t) + + tests := map[string]struct { + opts SignOptions + want []string + }{ + "all flags false (matches the legacy hack/push_and_sign_chart.sh)": { + opts: SignOptions{ + TlogUpload: false, + NewBundleFormat: false, + UseSigningConfig: false, + }, + want: []string{ + "sign", + "--key", testKeyCosignValue, + "--tlog-upload=false", + "--new-bundle-format=false", + "--use-signing-config=false", + testContainer, + }, + }, + "all flags true": { + opts: SignOptions{ + TlogUpload: true, + NewBundleFormat: true, + UseSigningConfig: true, + }, + want: []string{ + "sign", + "--key", testKeyCosignValue, + "--tlog-upload=true", + "--new-bundle-format=true", + "--use-signing-config=true", + testContainer, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got := SignArgs(testContainer, key, tc.opts) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("SignArgs mismatch\n got: %v\nwant: %v", got, tc.want) + } + }) + } +} + +func TestVerifyArgs(t *testing.T) { + key := mustKey(t) + + tests := map[string]struct { + opts VerifyOptions + want []string + }{ + "sha512 + ignore tlog (matches the legacy hack/push_and_sign_chart.sh)": { + opts: VerifyOptions{ + SignatureDigestAlgorithm: "sha512", + InsecureIgnoreTlog: true, + }, + want: []string{ + "verify", + "--key", testKeyCosignValue, + "--signature-digest-algorithm", "sha512", + "--insecure-ignore-tlog=true", + testContainer, + }, + }, + "no optional flags set": { + opts: VerifyOptions{}, + want: []string{ + "verify", + "--key", testKeyCosignValue, + testContainer, + }, + }, + "only digest algorithm set": { + opts: VerifyOptions{ + SignatureDigestAlgorithm: "sha256", + }, + want: []string{ + "verify", + "--key", testKeyCosignValue, + "--signature-digest-algorithm", "sha256", + testContainer, + }, + }, + "only ignore tlog set": { + opts: VerifyOptions{ + InsecureIgnoreTlog: true, + }, + want: []string{ + "verify", + "--key", testKeyCosignValue, + "--insecure-ignore-tlog=true", + testContainer, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got := VerifyArgs(testContainer, key, tc.opts) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("VerifyArgs mismatch\n got: %v\nwant: %v", got, tc.want) + } + }) + } +} + +// recordedCall captures the inputs of a single Runner invocation. +type recordedCall struct { + cmd string + args []string +} + +// recordingRunner returns a shell.Runner that records every invocation and +// returns the (optional) error supplied. It never executes anything. +func recordingRunner(calls *[]recordedCall, err error) func(ctx context.Context, workDir string, cmd string, args ...string) error { + return func(ctx context.Context, workDir string, cmd string, args ...string) error { + *calls = append(*calls, recordedCall{cmd: cmd, args: append([]string(nil), args...)}) + return err + } +} + +func TestSignWithOptionsInvokesRunner(t *testing.T) { + key := mustKey(t) + + var calls []recordedCall + runner := recordingRunner(&calls, nil) + + opts := SignOptions{} + if err := SignWithOptions(context.Background(), runner, "/usr/bin/cosign", testContainer, key, opts); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(calls) != 1 { + t.Fatalf("expected 1 runner call, got %d", len(calls)) + } + if calls[0].cmd != "/usr/bin/cosign" { + t.Errorf("expected cmd=/usr/bin/cosign, got %q", calls[0].cmd) + } + wantArgs := SignArgs(testContainer, key, opts) + if !reflect.DeepEqual(calls[0].args, wantArgs) { + t.Errorf("args mismatch\n got: %v\nwant: %v", calls[0].args, wantArgs) + } +} + +func TestSignWithOptionsPropagatesError(t *testing.T) { + key := mustKey(t) + + wantErr := errors.New("cosign failed") + var calls []recordedCall + runner := recordingRunner(&calls, wantErr) + + err := SignWithOptions(context.Background(), runner, "cosign", testContainer, key, SignOptions{}) + if !errors.Is(err, wantErr) { + t.Errorf("expected error to wrap %v, got %v", wantErr, err) + } +} + +func TestVerifyWithOptionsInvokesRunner(t *testing.T) { + key := mustKey(t) + + var calls []recordedCall + runner := recordingRunner(&calls, nil) + + opts := VerifyOptions{ + SignatureDigestAlgorithm: "sha512", + InsecureIgnoreTlog: true, + } + if err := VerifyWithOptions(context.Background(), runner, "cosign", testContainer, key, opts); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(calls) != 1 { + t.Fatalf("expected 1 runner call, got %d", len(calls)) + } + wantArgs := VerifyArgs(testContainer, key, opts) + if !reflect.DeepEqual(calls[0].args, wantArgs) { + t.Errorf("args mismatch\n got: %v\nwant: %v", calls[0].args, wantArgs) + } +}