diff --git a/api/go.mod b/api/go.mod index a5445cc68..7ceae5b8a 100644 --- a/api/go.mod +++ b/api/go.mod @@ -17,10 +17,13 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - golang.org/x/net v0.0.0-20211215060638-4ddde0e984e9 // indirect + golang.org/x/net v0.0.0-20220107192237-5cfca573fb4d // indirect + golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e // indirect golang.org/x/text v0.3.7 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect + k8s.io/api v0.23.4 // indirect k8s.io/klog/v2 v2.30.0 // indirect k8s.io/utils v0.0.0-20211208161948-7d6a63dca704 // indirect sigs.k8s.io/json v0.0.0-20211208200746-9f7c6b3444d2 // indirect diff --git a/api/go.sum b/api/go.sum index 0526ae80d..1c0d21c48 100644 --- a/api/go.sum +++ b/api/go.sum @@ -297,6 +297,8 @@ github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -334,7 +336,6 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8m github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= @@ -569,8 +570,9 @@ golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210825183410-e898025ed96a/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211209124913-491a49abca63/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20211215060638-4ddde0e984e9 h1:kmreh1vGI63l2FxOAYS3Yv6ATsi7lSTuwNSVbGfJV9I= golang.org/x/net v0.0.0-20211215060638-4ddde0e984e9/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220107192237-5cfca573fb4d h1:62NvYBuaanGXR2ZOfwDFkhhl6X1DUgf8qg3GuQvxZsE= +golang.org/x/net v0.0.0-20220107192237-5cfca573fb4d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -660,8 +662,9 @@ golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211029165221-6e7872819dc8 h1:M69LAlWZCshgp0QSzyDcSsSIejIEeuaCVpmwcKwyLMk= golang.org/x/sys v0.0.0-20211029165221-6e7872819dc8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e h1:fLOSk5Q00efkSvAm+4xcoXD+RRmLmmulPn5I3Y9F2EM= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -857,8 +860,9 @@ gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLks gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/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/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= @@ -893,8 +897,9 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -k8s.io/api v0.23.0 h1:WrL1gb73VSC8obi8cuYETJGXEoFNEh3LU0Pt+Sokgro= k8s.io/api v0.23.0/go.mod h1:8wmDdLBHBNxtOIytwLstXt5E9PddnZb0GaMcqsvDBpg= +k8s.io/api v0.23.4 h1:85gnfXQOWbJa1SiWGpE9EEtHs0UVvDyIsSMpEtl2D4E= +k8s.io/api v0.23.4/go.mod h1:i77F4JfyNNrhOjZF7OwwNJS5Y1S9dpwvb9iYRYRczfI= k8s.io/apiextensions-apiserver v0.23.0/go.mod h1:xIFAEEDlAZgpVBl/1VSjGDmLoXAWRG40+GsWhKhAxY4= k8s.io/apimachinery v0.23.0/go.mod h1:fFCTTBKvKcwTPFzjlcxp91uPFZr+JA0FubU4fLzzFYc= k8s.io/apimachinery v0.23.4 h1:fhnuMd/xUL3Cjfl64j5ULKZ1/J9n8NuQEgNL+WXWfdM= diff --git a/api/v1beta2/condition_types.go b/api/v1beta2/condition_types.go index 647b8aa7f..3b131bfcd 100644 --- a/api/v1beta2/condition_types.go +++ b/api/v1beta2/condition_types.go @@ -85,4 +85,8 @@ const ( // SymlinkUpdateFailedReason signals a failure in updating a symlink. SymlinkUpdateFailedReason string = "SymlinkUpdateFailed" + + // VerificationFailedReason signals a failure in verifying the signature of + // an artifact instance, such as a git commit or a helm chart. + VerificationFailedReason string = "VerificationFailed" ) diff --git a/api/v1beta2/helmchart_types.go b/api/v1beta2/helmchart_types.go index 2ce5a942f..8c634b138 100644 --- a/api/v1beta2/helmchart_types.go +++ b/api/v1beta2/helmchart_types.go @@ -84,6 +84,23 @@ type HelmChartSpec struct { // NOTE: Not implemented, provisional as of https://github.com/fluxcd/flux2/pull/2092 // +optional AccessFrom *acl.AccessFrom `json:"accessFrom,omitempty"` + + // VerificationKeyring for verifying the packaged chart's signature using a provenance file. + // +optional + VerificationKeyring *VerificationKeyring `json:"verificationKeyring,omitempty"` +} + +// VerificationKeyring contains enough info to get the public GPG key to be used for verifying +// the chart signature using a provenance file. +type VerificationKeyring struct { + // SecretRef is a reference to the secret that contains the public GPG key. + // +required + SecretRef meta.LocalObjectReference `json:"secretRef,omitempty"` + + // Key in the SecretRef that contains the public keyring in legacy GPG format. + // +kubebuilder:default:=pubring.gpg + // +optional + Key string `json:"key,omitempty"` } const ( @@ -154,6 +171,10 @@ const ( // ChartPackageSucceededReason signals that the package of the Helm // chart succeeded. ChartPackageSucceededReason string = "ChartPackageSucceeded" + + // ChartVerificationSucceededReason signals that the Helm chart's signature + // has been verified using it's provenance file. + ChartVerificationSucceededReason string = "ChartVerificationSucceeded" ) // GetConditions returns the status conditions of the object. diff --git a/api/v1beta2/zz_generated.deepcopy.go b/api/v1beta2/zz_generated.deepcopy.go index b789d81da..6cfbc3e35 100644 --- a/api/v1beta2/zz_generated.deepcopy.go +++ b/api/v1beta2/zz_generated.deepcopy.go @@ -442,6 +442,11 @@ func (in *HelmChartSpec) DeepCopyInto(out *HelmChartSpec) { *out = new(acl.AccessFrom) (*in).DeepCopyInto(*out) } + if in.VerificationKeyring != nil { + in, out := &in.VerificationKeyring, &out.VerificationKeyring + *out = new(VerificationKeyring) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmChartSpec. @@ -614,3 +619,19 @@ func (in *LocalHelmChartSourceReference) DeepCopy() *LocalHelmChartSourceReferen in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VerificationKeyring) DeepCopyInto(out *VerificationKeyring) { + *out = *in + out.SecretRef = in.SecretRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VerificationKeyring. +func (in *VerificationKeyring) DeepCopy() *VerificationKeyring { + if in == nil { + return nil + } + out := new(VerificationKeyring) + in.DeepCopyInto(out) + return out +} diff --git a/config/crd/bases/source.toolkit.fluxcd.io_helmcharts.yaml b/config/crd/bases/source.toolkit.fluxcd.io_helmcharts.yaml index a45d0370b..2026e7efa 100644 --- a/config/crd/bases/source.toolkit.fluxcd.io_helmcharts.yaml +++ b/config/crd/bases/source.toolkit.fluxcd.io_helmcharts.yaml @@ -404,6 +404,26 @@ spec: items: type: string type: array + verificationKeyring: + description: VerificationKeyring for verifying the packaged chart's + signature using a provenance file. + properties: + key: + default: pubring.gpg + description: Key in the SecretRef that contains the public keyring + in legacy GPG format. + type: string + secretRef: + description: SecretRef is a reference to the secret that contains + the public GPG key. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + type: object version: default: '*' description: Version is the chart version semver expression, ignored diff --git a/controllers/gitrepository_controller.go b/controllers/gitrepository_controller.go index 9c9189ca8..d7df0a90a 100644 --- a/controllers/gitrepository_controller.go +++ b/controllers/gitrepository_controller.go @@ -638,7 +638,7 @@ func (r *GitRepositoryReconciler) verifyCommitSignature(ctx context.Context, obj if err := r.Client.Get(ctx, publicKeySecret, secret); err != nil { e := &serror.Event{ Err: fmt.Errorf("PGP public keys secret error: %w", err), - Reason: "VerificationError", + Reason: sourcev1.VerificationFailedReason, } conditions.MarkFalse(obj, sourcev1.SourceVerifiedCondition, e.Reason, e.Err.Error()) return sreconcile.ResultEmpty, e diff --git a/controllers/gitrepository_controller_test.go b/controllers/gitrepository_controller_test.go index 7b6aeba35..e7daac2b7 100644 --- a/controllers/gitrepository_controller_test.go +++ b/controllers/gitrepository_controller_test.go @@ -1209,7 +1209,7 @@ func TestGitRepositoryReconciler_verifyCommitSignature(t *testing.T) { }, wantErr: true, assertConditions: []metav1.Condition{ - *conditions.FalseCondition(sourcev1.SourceVerifiedCondition, "VerificationError", "PGP public keys secret error: secrets \"none-existing\" not found"), + *conditions.FalseCondition(sourcev1.SourceVerifiedCondition, sourcev1.VerificationFailedReason, "PGP public keys secret error: secrets \"none-existing\" not found"), }, }, { diff --git a/controllers/helmchart_controller.go b/controllers/helmchart_controller.go index 3fa0c0271..820ffe56f 100644 --- a/controllers/helmchart_controller.go +++ b/controllers/helmchart_controller.go @@ -73,6 +73,7 @@ var helmChartReadyCondition = summarize.Conditions{ sourcev1.FetchFailedCondition, sourcev1.StorageOperationFailedCondition, sourcev1.ArtifactOutdatedCondition, + sourcev1.SourceVerifiedCondition, meta.ReadyCondition, meta.ReconcilingCondition, meta.StalledCondition, @@ -82,6 +83,7 @@ var helmChartReadyCondition = summarize.Conditions{ sourcev1.FetchFailedCondition, sourcev1.StorageOperationFailedCondition, sourcev1.ArtifactOutdatedCondition, + sourcev1.SourceVerifiedCondition, meta.StalledCondition, meta.ReconcilingCondition, }, @@ -467,13 +469,23 @@ func (r *HelmChartReconciler) buildFromHelmRepository(ctx context.Context, obj * opts.VersionMetadata = strconv.FormatInt(obj.Generation, 10) } + keyring, err := r.getProvenanceKeyring(ctx, obj) + if err != nil { + e := &serror.Event{ + Err: fmt.Errorf("failed to get public key for chart signature verification: %w", err), + Reason: sourcev1.VerificationFailedReason, + } + conditions.MarkFalse(obj, sourcev1.SourceVerifiedCondition, e.Reason, e.Err.Error()) + return sreconcile.ResultEmpty, e + } + opts.Keyring = keyring + // Build the chart ref := chart.RemoteReference{Name: obj.Spec.Chart, Version: obj.Spec.Version} build, err := cb.Build(ctx, ref, util.TempPathForObj("", ".tgz", obj), opts) if err != nil { return sreconcile.ResultEmpty, err } - *b = *build return sreconcile.ResultSuccess, nil } @@ -590,6 +602,16 @@ func (r *HelmChartReconciler) buildFromTarballArtifact(ctx context.Context, obj } opts.VersionMetadata += strconv.FormatInt(obj.Generation, 10) } + keyring, err := r.getProvenanceKeyring(ctx, obj) + if err != nil { + e := &serror.Event{ + Err: fmt.Errorf("failed to get public key for chart signature verification: %w", err), + Reason: sourcev1.VerificationFailedReason, + } + conditions.MarkFalse(obj, sourcev1.SourceVerifiedCondition, e.Reason, e.Err.Error()) + return sreconcile.ResultEmpty, e + } + opts.Keyring = keyring // Build chart cb := chart.NewLocalBuilder(dm) @@ -670,6 +692,19 @@ func (r *HelmChartReconciler) reconcileArtifact(ctx context.Context, obj *source return sreconcile.ResultEmpty, e } + // the provenance file artifact is not recorded, but it shadows the HelmChart artifact + // under the assumption that the file is always available at "chart.tgz.prov" + if b.ProvFilePath != "" { + provArtifact := r.Storage.NewArtifactFor(obj.Kind, obj.GetObjectMeta(), b.Version, fmt.Sprintf("%s-%s.tgz.prov", b.Name, b.Version)) + if err = r.Storage.CopyFromPath(&provArtifact, b.ProvFilePath); err != nil { + e := &serror.Event{ + Err: fmt.Errorf("unable to copy Helm chart provenance file to storage: %w", err), + Reason: sourcev1.ArchiveOperationFailedReason, + } + conditions.MarkTrue(obj, sourcev1.StorageOperationFailedCondition, e.Reason, e.Err.Error()) + } + } + // Record it on the object obj.Status.Artifact = artifact.DeepCopy() obj.Status.ObservedChartName = b.Name @@ -763,8 +798,18 @@ func (r *HelmChartReconciler) garbageCollect(ctx context.Context, obj *sourcev1. obj.Status.Artifact = nil return nil } + if obj.GetArtifact() != nil { - if deleted, err := r.Storage.RemoveAllButCurrent(*obj.GetArtifact()); err != nil { + localPath := r.Storage.LocalPath(*obj.GetArtifact()) + provFilePath := localPath + ".prov" + dir := filepath.Dir(localPath) + callback := func(path string, info os.FileInfo) bool { + if path != localPath && path != provFilePath && info.Mode()&os.ModeSymlink != os.ModeSymlink { + return true + } + return false + } + if deleted, err := r.Storage.RemoveConditionally(dir, callback); err != nil { return &serror.Event{ Err: fmt.Errorf("garbage collection of old artifacts failed: %w", err), Reason: "GarbageCollectionFailed", @@ -991,6 +1036,15 @@ func observeChartBuild(obj *sourcev1.HelmChart, build *chart.Build, err error) { conditions.Delete(obj, sourcev1.BuildFailedCondition) } + if build.VerificationSignature != nil && build.ProvFilePath != "" { + var sigVerMsg strings.Builder + sigVerMsg.WriteString(fmt.Sprintf("verified chart hash: '%s'", build.VerificationSignature.FileHash)) + sigVerMsg.WriteString(fmt.Sprintf(" signed by: '%s'", build.VerificationSignature.Identity)) + sigVerMsg.WriteString(fmt.Sprintf(" with key: '%X'", build.VerificationSignature.KeyFingerprint)) + + conditions.MarkTrue(obj, sourcev1.SourceVerifiedCondition, sourcev1.ChartVerificationSucceededReason, sigVerMsg.String()) + } + if err != nil { var buildErr *chart.BuildError if ok := errors.As(err, &buildErr); !ok { @@ -1021,3 +1075,26 @@ func reasonForBuild(build *chart.Build) string { } return sourcev1.ChartPullSucceededReason } + +func (r *HelmChartReconciler) getProvenanceKeyring(ctx context.Context, chart *sourcev1.HelmChart) ([]byte, error) { + if chart.Spec.VerificationKeyring == nil { + conditions.Delete(chart, sourcev1.SourceVerifiedCondition) + return nil, nil + } + name := types.NamespacedName{ + Namespace: chart.GetNamespace(), + Name: chart.Spec.VerificationKeyring.SecretRef.Name, + } + var secret corev1.Secret + err := r.Client.Get(ctx, name, &secret) + if err != nil { + return nil, err + } + key := chart.Spec.VerificationKeyring.Key + val, ok := secret.Data[key] + if !ok { + err = fmt.Errorf("secret doesn't contain the advertised verification keyring name %s", key) + return nil, err + } + return val, nil +} diff --git a/controllers/helmchart_controller_test.go b/controllers/helmchart_controller_test.go index 43d568b85..832277949 100644 --- a/controllers/helmchart_controller_test.go +++ b/controllers/helmchart_controller_test.go @@ -52,6 +52,8 @@ import ( sreconcile "github.com/fluxcd/source-controller/internal/reconcile" ) +const publicKeyFileName = "pub.gpg" + func TestHelmChartReconciler_Reconcile(t *testing.T) { g := NewWithT(t) @@ -163,6 +165,140 @@ func TestHelmChartReconciler_Reconcile(t *testing.T) { }, timeout).Should(BeTrue()) } +func TestHelmChartReconciler_ReconcileWithSigVerification(t *testing.T) { + g := NewWithT(t) + + const ( + chartName = "helmchart" + chartVersion = "0.2.0" + chartPath = "testdata/charts/helmchart" + ) + + server, err := helmtestserver.NewTempHelmServer() + g.Expect(err).NotTo(HaveOccurred()) + defer os.RemoveAll(server.Root()) + + publicKeyPath := fmt.Sprintf("%s/%s", server.Root(), publicKeyFileName) + + g.Expect(server.PackageSignedChartWithVersion(chartPath, chartVersion, publicKeyPath)).To(Succeed()) + g.Expect(server.GenerateIndex()).To(Succeed()) + + server.Start() + defer server.Stop() + + ns, err := testEnv.CreateNamespace(ctx, "helmchart") + g.Expect(err).ToNot(HaveOccurred()) + defer func() { g.Expect(testEnv.Delete(ctx, ns)).To(Succeed()) }() + + repository := &sourcev1.HelmRepository{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "helmrepository-", + Namespace: ns.Name, + }, + Spec: sourcev1.HelmRepositorySpec{ + URL: server.URL(), + }, + } + g.Expect(testEnv.CreateAndWait(ctx, repository)).To(Succeed()) + + keyring, err := os.ReadFile(publicKeyPath) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(keyring).ToNot(BeEmpty()) + + keyringSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "keyring-secret", + Namespace: ns.Name, + }, + Data: map[string][]byte{ + publicKeyFileName: keyring, + }, + } + g.Expect(testEnv.CreateAndWait(ctx, keyringSecret)).To(Succeed()) + + obj := &sourcev1.HelmChart{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "helmrepository-reconcile-", + Namespace: ns.Name, + }, + Spec: sourcev1.HelmChartSpec{ + Chart: chartName, + Version: chartVersion, + SourceRef: sourcev1.LocalHelmChartSourceReference{ + Kind: sourcev1.HelmRepositoryKind, + Name: repository.Name, + }, + VerificationKeyring: &sourcev1.VerificationKeyring{ + SecretRef: meta.LocalObjectReference{ + Name: keyringSecret.Name, + }, + Key: publicKeyFileName, + }, + }, + } + g.Expect(testEnv.Create(ctx, obj)).To(Succeed()) + + key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace} + + // Wait for finalizer to be set + g.Eventually(func() bool { + if err := testEnv.Get(ctx, key, obj); err != nil { + return false + } + return len(obj.Finalizers) > 0 + }, timeout).Should(BeTrue()) + + // Wait for HelmChart to be Ready + g.Eventually(func() bool { + if err := testEnv.Get(ctx, key, obj); err != nil { + return false + } + if !conditions.IsReady(obj) || obj.Status.Artifact == nil { + return false + } + readyCondition := conditions.Get(obj, meta.ReadyCondition) + return obj.Generation == readyCondition.ObservedGeneration && + obj.Generation == obj.Status.ObservedGeneration + }, timeout).Should(BeTrue()) + + // Check if the object status is valid. + condns := &status.Conditions{NegativePolarity: helmChartReadyCondition.NegativePolarity} + checker := status.NewChecker(testEnv.Client, testEnv.GetScheme(), condns) + checker.CheckErr(ctx, obj) + + // kstatus client conformance check. + u, err := patch.ToUnstructured(obj) + g.Expect(err).ToNot(HaveOccurred()) + res, err := kstatus.Compute(u) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(res.Status).To(Equal(kstatus.CurrentStatus)) + + // Patch the object with reconcile request annotation. + patchHelper, err := patch.NewHelper(obj, testEnv.Client) + g.Expect(err).ToNot(HaveOccurred()) + annotations := map[string]string{ + meta.ReconcileRequestAnnotation: "now", + } + obj.SetAnnotations(annotations) + g.Expect(patchHelper.Patch(ctx, obj)).ToNot(HaveOccurred()) + g.Eventually(func() bool { + if err := testEnv.Get(ctx, key, obj); err != nil { + return false + } + return obj.Status.LastHandledReconcileAt == "now" + }, timeout).Should(BeTrue()) + + g.Expect(testEnv.Delete(ctx, obj)).To(Succeed()) + + // Wait for HelmChart to be deleted + g.Eventually(func() bool { + if err := testEnv.Get(ctx, key, obj); err != nil { + return apierrors.IsNotFound(err) + } + return false + }, timeout).Should(BeTrue()) +} + func TestHelmChartReconciler_reconcileStorage(t *testing.T) { tests := []struct { name string @@ -183,10 +319,18 @@ func TestHelmChartReconciler_reconcileStorage(t *testing.T) { Path: fmt.Sprintf("/reconcile-storage/%s.txt", v), Revision: v, } + provArtifact := &sourcev1.Artifact{ + Path: fmt.Sprintf("/reconcile-storage/%s.txt.prov", v), + Revision: v, + } + if err := testStorage.MkdirAll(*obj.Status.Artifact); err != nil { return err } - if err := testStorage.AtomicWriteFile(obj.Status.Artifact, strings.NewReader(v), 0644); err != nil { + if err := testStorage.AtomicWriteFile(obj.Status.Artifact, strings.NewReader(v), 0o644); err != nil { + return err + } + if err := testStorage.AtomicWriteFile(provArtifact, strings.NewReader(v), 0o644); err != nil { return err } } @@ -204,6 +348,9 @@ func TestHelmChartReconciler_reconcileStorage(t *testing.T) { "/reconcile-storage/c.txt", "!/reconcile-storage/b.txt", "!/reconcile-storage/a.txt", + "/reconcile-storage/c.txt.prov", + "!/reconcile-storage/b.txt.prov", + "!/reconcile-storage/a.txt.prov", }, want: sreconcile.ResultSuccess, }, @@ -237,7 +384,7 @@ func TestHelmChartReconciler_reconcileStorage(t *testing.T) { if err := testStorage.MkdirAll(*obj.Status.Artifact); err != nil { return err } - if err := testStorage.AtomicWriteFile(obj.Status.Artifact, strings.NewReader("file"), 0644); err != nil { + if err := testStorage.AtomicWriteFile(obj.Status.Artifact, strings.NewReader("file"), 0o644); err != nil { return err } return nil @@ -311,14 +458,19 @@ func TestHelmChartReconciler_reconcileSource(t *testing.T) { } g.Expect(storage.Archive(gitArtifact, "testdata/charts", nil)).To(Succeed()) + keyring, err := os.ReadFile("testdata/charts/pub.gpg") + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(keyring).ToNot(BeEmpty()) + tests := []struct { - name string - source sourcev1.Source - beforeFunc func(obj *sourcev1.HelmChart) - want sreconcile.Result - wantErr error - assertFunc func(g *WithT, build chart.Build, obj sourcev1.HelmChart) - cleanFunc func(g *WithT, build *chart.Build) + name string + source sourcev1.Source + keyringSecret *corev1.Secret + beforeFunc func(obj *sourcev1.HelmChart) + want sreconcile.Result + wantErr error + assertFunc func(g *WithT, build chart.Build, obj sourcev1.HelmChart) + cleanFunc func(g *WithT, build *chart.Build) }{ { name: "Observes Artifact revision and build result", @@ -354,6 +506,59 @@ func TestHelmChartReconciler_reconcileSource(t *testing.T) { g.Expect(os.Remove(build.Path)).To(Succeed()) }, }, + { + name: "Observes Artifact revision and build result with valid signature", + source: &sourcev1.GitRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gitrepository", + Namespace: "default", + }, + Status: sourcev1.GitRepositoryStatus{ + Artifact: gitArtifact, + }, + }, + keyringSecret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "keyring-secret", + Namespace: "default", + }, + Data: map[string][]byte{ + publicKeyFileName: keyring, + }, + }, + beforeFunc: func(obj *sourcev1.HelmChart) { + obj.Spec.Chart = "testdata/charts/helmchart-0.1.0.tgz" + obj.Spec.SourceRef = sourcev1.LocalHelmChartSourceReference{ + Name: "gitrepository", + Kind: sourcev1.GitRepositoryKind, + } + obj.Spec.VerificationKeyring = &sourcev1.VerificationKeyring{ + SecretRef: meta.LocalObjectReference{ + Name: "keyring-secret", + }, + Key: publicKeyFileName, + } + }, + want: sreconcile.ResultSuccess, + assertFunc: func(g *WithT, build chart.Build, obj sourcev1.HelmChart) { + g.Expect(build.Complete()).To(BeTrue()) + g.Expect(build.Name).To(Equal("helmchart")) + g.Expect(build.Version).To(Equal("0.1.0")) + g.Expect(build.Path).To(BeARegularFile()) + g.Expect(build.VerificationSignature).ToNot(BeNil()) + g.Expect(build.ProvFilePath).To(BeARegularFile()) + + g.Expect(obj.Status.ObservedSourceArtifactRevision).To(Equal(gitArtifact.Revision)) + g.Expect(obj.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ + *conditions.TrueCondition(sourcev1.ArtifactOutdatedCondition, "NewChart", "pulled 'helmchart' chart with version '0.1.0'"), + *conditions.TrueCondition(sourcev1.SourceVerifiedCondition, sourcev1.ChartVerificationSucceededReason, "verified chart hash: 'sha256:007c7b7446eebcb18caeffe9898a3356ba1795f54df40ad39cfcc7382874a10a' signed by: 'TestUser' with key: '943CB5929ECDA2B5B5EC88BC7035BA97D32A87C1'"), + })) + }, + cleanFunc: func(g *WithT, build *chart.Build) { + g.Expect(os.Remove(build.Path)).To(Succeed()) + g.Expect(os.Remove(build.ProvFilePath)).To(Succeed()) + }, + }, { name: "Error on unavailable source", beforeFunc: func(obj *sourcev1.HelmChart) { @@ -458,6 +663,9 @@ func TestHelmChartReconciler_reconcileSource(t *testing.T) { if tt.source != nil { clientBuilder.WithRuntimeObjects(tt.source) } + if tt.keyringSecret != nil { + clientBuilder.WithRuntimeObjects(tt.keyringSecret) + } r := &HelmChartReconciler{ Client: clientBuilder.Build(), @@ -988,7 +1196,7 @@ func TestHelmChartReconciler_reconcileArtifact(t *testing.T) { }, afterFunc: func(t *WithT, obj *sourcev1.HelmChart) { t.Expect(obj.GetArtifact()).ToNot(BeNil()) - t.Expect(obj.GetArtifact().Checksum).To(Equal("bbdf96023c912c393b49d5238e227576ed0d20d1bb145d7476d817b80e20c11a")) + t.Expect(obj.GetArtifact().Checksum).To(Equal("007c7b7446eebcb18caeffe9898a3356ba1795f54df40ad39cfcc7382874a10a")) t.Expect(obj.GetArtifact().Revision).To(Equal("0.1.0")) t.Expect(obj.Status.URL).ToNot(BeEmpty()) t.Expect(obj.Status.ObservedChartName).To(Equal("helmchart")) @@ -1049,7 +1257,7 @@ func TestHelmChartReconciler_reconcileArtifact(t *testing.T) { }, afterFunc: func(t *WithT, obj *sourcev1.HelmChart) { t.Expect(obj.GetArtifact()).ToNot(BeNil()) - t.Expect(obj.GetArtifact().Checksum).To(Equal("bbdf96023c912c393b49d5238e227576ed0d20d1bb145d7476d817b80e20c11a")) + t.Expect(obj.GetArtifact().Checksum).To(Equal("007c7b7446eebcb18caeffe9898a3356ba1795f54df40ad39cfcc7382874a10a")) t.Expect(obj.GetArtifact().Revision).To(Equal("0.1.0")) t.Expect(obj.Status.URL).ToNot(BeEmpty()) t.Expect(obj.Status.ObservedChartName).To(Equal("helmchart")) @@ -1185,6 +1393,105 @@ func TestHelmChartReconciler_getHelmRepositorySecret(t *testing.T) { } } +func TestHelmChartReconciler_getVerificationKeyring(t *testing.T) { + secret := &corev1.Secret{ + TypeMeta: metav1.TypeMeta{ + Kind: "Secret", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "keyring-secret", + Namespace: "foo", + }, + Data: map[string][]byte{ + publicKeyFileName: []byte("bar"), + }, + } + clientBuilder := fake.NewClientBuilder() + clientBuilder.WithObjects(secret) + + r := &HelmChartReconciler{ + Client: clientBuilder.Build(), + } + + tests := []struct { + name string + chart *sourcev1.HelmChart + want []byte + wantErr bool + }{ + { + name: "Existing secret reference", + chart: &sourcev1.HelmChart{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: secret.Namespace, + }, + Spec: sourcev1.HelmChartSpec{ + VerificationKeyring: &sourcev1.VerificationKeyring{ + SecretRef: meta.LocalObjectReference{ + Name: "keyring-secret", + }, + Key: publicKeyFileName, + }, + }, + }, + want: []byte("bar"), + }, + { + name: "Empty secret reference", + chart: &sourcev1.HelmChart{ + Spec: sourcev1.HelmChartSpec{ + VerificationKeyring: nil, + }, + }, + want: nil, + }, + { + name: "Error on client error", + chart: &sourcev1.HelmChart{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "different", + }, + Spec: sourcev1.HelmChartSpec{ + VerificationKeyring: &sourcev1.VerificationKeyring{ + SecretRef: meta.LocalObjectReference{ + Name: "keyring-secret", + }, + Key: publicKeyFileName, + }, + }, + }, + wantErr: true, + }, + { + name: "Error on invalid key", + chart: &sourcev1.HelmChart{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "foo", + }, + Spec: sourcev1.HelmChartSpec{ + VerificationKeyring: &sourcev1.VerificationKeyring{ + SecretRef: meta.LocalObjectReference{ + Name: "keyring-secret", + }, + Key: "invalid-key", + }, + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + got, err := r.getProvenanceKeyring(context.TODO(), tt.chart) + g.Expect(err != nil).To(Equal(tt.wantErr)) + g.Expect(got).To(Equal(tt.want)) + }) + } +} + func TestHelmChartReconciler_getSource(t *testing.T) { mocks := []client.Object{ &sourcev1.HelmRepository{ diff --git a/controllers/storage.go b/controllers/storage.go index 55f9a077c..54bf06409 100644 --- a/controllers/storage.go +++ b/controllers/storage.go @@ -53,6 +53,10 @@ type Storage struct { Timeout time.Duration `json:"timeout"` } +// removeFileCallback is a function which determines whether the +// provided file should be removed from the filesystem. +type removeFileCallback func(path string, info os.FileInfo) bool + // NewStorage creates the storage helper for a given path and hostname. func NewStorage(basePath string, hostname string, timeout time.Duration) (*Storage, error) { if f, err := os.Stat(basePath); os.IsNotExist(err) || !f.IsDir() { @@ -145,6 +149,36 @@ func (s *Storage) RemoveAllButCurrent(artifact sourcev1.Artifact) ([]string, err return deletedFiles, nil } +// RemoveConditionally walks through the provided dir and then deletes all files +// for which any of the callbacks return true. +func (s *Storage) RemoveConditionally(dir string, callbacks ...removeFileCallback) ([]string, error) { + deletedFiles := []string{} + var errors []string + _ = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + errors = append(errors, err.Error()) + return nil + } + for _, callback := range callbacks { + if callback(path, info) { + if err := os.Remove(path); err != nil { + errors = append(errors, info.Name()) + } else { + // Collect the successfully deleted file paths. + deletedFiles = append(deletedFiles, path) + } + break + } + } + return nil + }) + + if len(errors) > 0 { + return deletedFiles, fmt.Errorf("failed to remove files: %s", strings.Join(errors, " ")) + } + return deletedFiles, nil +} + // ArtifactExist returns a boolean indicating whether the v1beta1.Artifact exists in storage and is a regular file. func (s *Storage) ArtifactExist(artifact sourcev1.Artifact) bool { fi, err := os.Lstat(s.LocalPath(artifact)) diff --git a/controllers/testdata/charts/helmchart-0.1.0.tgz b/controllers/testdata/charts/helmchart-0.1.0.tgz index f64a32eee..186a1ddb7 100644 Binary files a/controllers/testdata/charts/helmchart-0.1.0.tgz and b/controllers/testdata/charts/helmchart-0.1.0.tgz differ diff --git a/controllers/testdata/charts/helmchart-0.1.0.tgz.prov b/controllers/testdata/charts/helmchart-0.1.0.tgz.prov new file mode 100644 index 000000000..7c44d8c25 --- /dev/null +++ b/controllers/testdata/charts/helmchart-0.1.0.tgz.prov @@ -0,0 +1,26 @@ +-----BEGIN PGP SIGNED MESSAGE----- +Hash: SHA512 + +apiVersion: v2 +appVersion: 1.16.0 +description: A Helm chart for Kubernetes +name: helmchart +type: application +version: 0.1.0 + +... +files: + helmchart-0.1.0.tgz: sha256:007c7b7446eebcb18caeffe9898a3356ba1795f54df40ad39cfcc7382874a10a +-----BEGIN PGP SIGNATURE----- + +wsDcBAEBCgAQBQJiKwNBCRBwNbqX0yqHwQAACj8MABCY6mVrWaJdC64PbhTTonVE +97MZZpQBT+CZIRAecfkvcTeMTBeKh/yRwsSmjwo46eKOpNFJ1eQVHqVcKWLfBn3Z +AijuXTaISl8SnQyKPF2Z8n+YrYwh9OWPUX2CpUQstx+snSLDuv5ltWIgRlzfHAUN +hwzsgjs8bpHe8wZTgnASUVbcMMYQXCcovbXB6NATDLkZLHBWWEISicOl6VYLLl2D +kZg7LDcDKPcPmKJ6WtVurkyWXhK3jdYzlaOQWjs2nLIH/CdlmAygELuWexsOZAhY +MEauKEMoVzDQF5oaNA78AzlBLGogxao5fBYtAAHGb5tQdnVRUeSci+7IR0LHsS05 +YF/UnUF69GSESfoKIBvQuzex4BRCLBwayq6CSyrpZQ2+Vg4ARPo7LFg7Wy0zvC9Z +NxGnIeh1az9hltdzPgg6ZahPZB+eMF+t9ouAz9OZ3kxYUDmoE+Z+NqRWsPi27Cxk +CSw9EfJfDsputN/wj4NAxZKfqauMtS5sgaSgtrW+zA== +=mfBq +-----END PGP SIGNATURE----- \ No newline at end of file diff --git a/controllers/testdata/charts/pub.gpg b/controllers/testdata/charts/pub.gpg new file mode 100644 index 000000000..291f8abd8 Binary files /dev/null and b/controllers/testdata/charts/pub.gpg differ diff --git a/controllers/testdata/charts/sec.gpg b/controllers/testdata/charts/sec.gpg new file mode 100644 index 000000000..be43af3a0 Binary files /dev/null and b/controllers/testdata/charts/sec.gpg differ diff --git a/docs/api/source.md b/docs/api/source.md index 6f0d1621b..600aa4125 100644 --- a/docs/api/source.md +++ b/docs/api/source.md @@ -668,6 +668,20 @@ references to this object. NOTE: Not implemented, provisional as of https://github.com/fluxcd/flux2/pull/2092
+verificationKeyring
VerificationKeyring for verifying the packaged chart’s signature using a provenance file.
+verificationKeyring
VerificationKeyring for verifying the packaged chart’s signature using a provenance file.
++(Appears on: +HelmChartSpec) +
+VerificationKeyring contains enough info to get the public GPG key to be used for verifying +the chart signature using a provenance file.
+Field | +Description | +
---|---|
+secretRef + + +github.com/fluxcd/pkg/apis/meta.LocalObjectReference + + + |
+
+ SecretRef is a reference to the secret that contains the public GPG key. + |
+
+key + +string + + |
+
+(Optional)
+ Key in the SecretRef that contains the public keyring in legacy GPG format. + |
+
This page was automatically generated with gen-crd-api-reference-docs