Add SigningKey to CommitSpec

Signed-off-by: LWJ <lwjames1996@gmail.com>
This commit is contained in:
LWJ 2021-03-24 21:10:23 +00:00
parent daad724ad2
commit 4aa56f1013
7 changed files with 303 additions and 3 deletions

View File

@ -106,6 +106,9 @@ type CommitSpec struct {
// AuthorEmail gives the email to provide when making a commit
// +required
AuthorEmail string `json:"authorEmail"`
// SigningKey provides the option to sign commits with a GPG key
// +optional
SigningKey *SigningKey `json:"signingKey,omitempty"`
// MessageTemplate provides a template for the commit message,
// into which will be interpolated the details of the change made.
// +optional
@ -142,6 +145,16 @@ type ImageUpdateAutomationStatus struct {
meta.ReconcileRequestStatus `json:",inline"`
}
// SigningKey references a Kubernetes secret that contains a GPG keypair
type SigningKey struct {
// SecretRef holds the name to a secret that contains a 'value' key
// with the ASCII Armored file (.asc) containing the GPG signing
// keypair as the value. It must be in the same namespace as the
// ImageUpdateAutomation.
// +required
SecretRef *meta.LocalObjectReference `json:"secretRef,omitempty"`
}
const (
// GitNotAvailableReason is used for ConditionReady when the
// automation run cannot proceed because the git repository is

View File

@ -21,6 +21,7 @@ limitations under the License.
package v1alpha1
import (
"github.com/fluxcd/pkg/apis/meta"
"k8s.io/apimachinery/pkg/apis/meta/v1"
runtime "k8s.io/apimachinery/pkg/runtime"
)
@ -28,6 +29,11 @@ import (
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *CommitSpec) DeepCopyInto(out *CommitSpec) {
*out = *in
if in.SigningKey != nil {
in, out := &in.SigningKey, &out.SigningKey
*out = new(SigningKey)
(*in).DeepCopyInto(*out)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CommitSpec.
@ -125,7 +131,7 @@ func (in *ImageUpdateAutomationSpec) DeepCopyInto(out *ImageUpdateAutomationSpec
*out = new(UpdateStrategy)
**out = **in
}
out.Commit = in.Commit
in.Commit.DeepCopyInto(&out.Commit)
if in.Push != nil {
in, out := &in.Push, &out.Push
*out = new(PushSpec)
@ -189,6 +195,26 @@ func (in *PushSpec) DeepCopy() *PushSpec {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *SigningKey) DeepCopyInto(out *SigningKey) {
*out = *in
if in.SecretRef != nil {
in, out := &in.SecretRef, &out.SecretRef
*out = new(meta.LocalObjectReference)
**out = **in
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SigningKey.
func (in *SigningKey) DeepCopy() *SigningKey {
if in == nil {
return nil
}
out := new(SigningKey)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *UpdateStrategy) DeepCopyInto(out *UpdateStrategy) {
*out = *in

View File

@ -80,6 +80,23 @@ spec:
message, into which will be interpolated the details of the
change made.
type: string
signingKey:
description: SigningKey provides the option to sign commits with
a GPG key
properties:
secretRef:
description: SecretRef holds the name to a secret that contains
a 'value' key with the ASCII Armored file (.asc) containing
the GPG signing keypair as the value. It must be in the
same namespace as the ImageUpdateAutomation.
properties:
name:
description: Name of the referent
type: string
required:
- name
type: object
type: object
required:
- authorEmail
- authorName

View File

@ -17,9 +17,11 @@ limitations under the License.
package controllers
import (
"bytes"
"context"
"errors"
"fmt"
"golang.org/x/crypto/openpgp"
"io/ioutil"
"math"
"os"
@ -227,10 +229,15 @@ func (r *ImageUpdateAutomationReconciler) Reconcile(ctx context.Context, req ctr
var statusMessage string
var signingEntity *openpgp.Entity
if auto.Spec.Commit.SigningKey != nil {
signingEntity, err = r.getSigningEntity(ctx, auto)
}
// The status message depends on what happens next. Since there's
// more than one way to succeed, there's some if..else below, and
// early returns only on failure.
if rev, err := commitAll(ctx, repo, &auto.Spec.Commit, templateValues); err != nil {
if rev, err := commitAll(repo, &auto.Spec.Commit, templateValues, signingEntity); err != nil {
if err == errNoChanges {
r.event(ctx, auto, events.EventSeverityInfo, "no updates made")
log.V(debug).Info("no changes made in working directory; no commit")
@ -439,7 +446,7 @@ func switchBranch(repo *gogit.Repository, pushBranch string) error {
var errNoChanges error = errors.New("no changes made to working directory")
func commitAll(ctx context.Context, repo *gogit.Repository, commit *imagev1.CommitSpec, values TemplateData) (string, error) {
func commitAll(repo *gogit.Repository, commit *imagev1.CommitSpec, values TemplateData, ent *openpgp.Entity) (string, error) {
working, err := repo.Worktree()
if err != nil {
return "", err
@ -473,6 +480,7 @@ func commitAll(ctx context.Context, repo *gogit.Repository, commit *imagev1.Comm
Email: commit.AuthorEmail,
When: time.Now(),
},
SignKey: ent,
}); err != nil {
return "", err
}
@ -480,6 +488,36 @@ func commitAll(ctx context.Context, repo *gogit.Repository, commit *imagev1.Comm
return rev.String(), nil
}
// getSigningEntity retrieves an OpenPGP entity referenced by the
// provided imagev1.ImageUpdateAutomation for git commit signing
func (r *ImageUpdateAutomationReconciler) getSigningEntity(ctx context.Context, auto imagev1.ImageUpdateAutomation) (*openpgp.Entity, error) {
// get kubernetes secret
secretName := types.NamespacedName{
Namespace: auto.GetNamespace(),
Name: auto.Spec.Commit.SigningKey.SecretRef.Name,
}
var secret corev1.Secret
if err := r.Get(ctx, secretName, &secret); err != nil {
return nil, fmt.Errorf("could not find signing key secret '%s': %w", secretName, err)
}
// get data from secret
data, ok := secret.Data["value"]
if !ok {
return nil, fmt.Errorf("signing key secret '%s' does not contain a 'value' key", secretName)
}
// read entity from secret value
entities, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("could not read signing key from secret '%s': %w", secretName, err)
}
if len(entities) > 1 {
return nil, fmt.Errorf("multiple entities read from secret '%s', could not determine which signing key to use", secretName)
}
return entities[0], nil
}
// push pushes the branch given to the origin using the git library
// indicated by `impl`. It's passed both the path to the repo and a
// gogit.Repository value, since the latter may as well be used if the

View File

@ -20,6 +20,8 @@ import (
"bytes"
"context"
"fmt"
"golang.org/x/crypto/openpgp"
"golang.org/x/crypto/openpgp/armor"
"io/ioutil"
"math/rand"
"net/url"
@ -388,6 +390,161 @@ Images:
})
})
Context("commit signing", func() {
var localRepo *git.Repository
// generate keypair for signing
pgpEntity, err := openpgp.NewEntity("", "", "", nil)
Expect(err).ToNot(HaveOccurred())
BeforeEach(func() {
Expect(initGitRepo(gitServer, "testdata/appconfig", branch, repositoryPath)).To(Succeed())
repoURL := gitServer.HTTPAddressWithCredentials() + repositoryPath
var err error
localRepo, err = git.Clone(memory.NewStorage(), memfs.New(), &git.CloneOptions{
URL: repoURL,
RemoteName: "origin",
ReferenceName: plumbing.NewBranchReferenceName(branch),
})
Expect(err).ToNot(HaveOccurred())
gitRepoKey := types.NamespacedName{
Name: "image-auto-" + randStringRunes(5),
Namespace: namespace.Name,
}
gitRepo := &sourcev1.GitRepository{
ObjectMeta: metav1.ObjectMeta{
Name: gitRepoKey.Name,
Namespace: namespace.Name,
},
Spec: sourcev1.GitRepositorySpec{
URL: repoURL,
Interval: metav1.Duration{Duration: time.Minute},
},
}
Expect(k8sClient.Create(context.Background(), gitRepo)).To(Succeed())
policyKey := types.NamespacedName{
Name: "policy-" + randStringRunes(5),
Namespace: namespace.Name,
}
// NB not testing the image reflector controller; this
// will make a "fully formed" ImagePolicy object.
policy := &imagev1_reflect.ImagePolicy{
ObjectMeta: metav1.ObjectMeta{
Name: policyKey.Name,
Namespace: policyKey.Namespace,
},
Spec: imagev1_reflect.ImagePolicySpec{
ImageRepositoryRef: meta.LocalObjectReference{
Name: "not-expected-to-exist",
},
Policy: imagev1_reflect.ImagePolicyChoice{
SemVer: &imagev1_reflect.SemVerPolicy{
Range: "1.x",
},
},
},
Status: imagev1_reflect.ImagePolicyStatus{
LatestImage: "helloworld:v1.0.0",
},
}
Expect(k8sClient.Create(context.Background(), policy)).To(Succeed())
Expect(k8sClient.Status().Update(context.Background(), policy)).To(Succeed())
// Insert a setter reference into the deployment file,
// before creating the automation object itself.
commitInRepo(repoURL, branch, "Install setter marker", func(tmp string) {
replaceMarker(tmp, policyKey)
})
// pull the head commit we just pushed, so it's not
// considered a new commit when checking for a commit
// made by automation.
waitForNewHead(localRepo, branch)
// now create the automation object, and let it (one
// hopes!) make a commit itself.
updateKey := types.NamespacedName{
Namespace: namespace.Name,
Name: "update-test",
}
updateBySetters := &imagev1.ImageUpdateAutomation{
ObjectMeta: metav1.ObjectMeta{
Name: updateKey.Name,
Namespace: updateKey.Namespace,
},
Spec: imagev1.ImageUpdateAutomationSpec{
Interval: metav1.Duration{Duration: 2 * time.Hour}, // this is to ensure any subsequent run should be outside the scope of the testing
Checkout: imagev1.GitCheckoutSpec{
GitRepositoryRef: meta.LocalObjectReference{
Name: gitRepoKey.Name,
},
Branch: branch,
},
Update: &imagev1.UpdateStrategy{
Strategy: imagev1.UpdateStrategySetters,
},
Commit: imagev1.CommitSpec{
SigningKey: &imagev1.SigningKey{},
},
},
}
// configure OpenPGP armor encoder
b := bytes.NewBuffer(nil)
w, err := armor.Encode(b, openpgp.PrivateKeyType, nil)
Expect(err).ToNot(HaveOccurred())
// serialize private key
err = pgpEntity.SerializePrivate(w, nil)
Expect(err).ToNot(HaveOccurred())
err = w.Close()
Expect(err).ToNot(HaveOccurred())
// create the secret containing signing key
sec := &corev1.Secret{
Data: map[string][]byte{
"value": b.Bytes(),
},
}
sec.Name = "signing-key-secret-" + randStringRunes(5)
sec.Namespace = namespace.Name
Expect(k8sClient.Create(context.Background(), sec)).To(Succeed())
updateBySetters.Spec.Commit.SigningKey.SecretRef = &meta.LocalObjectReference{Name: sec.Name}
Expect(k8sClient.Create(context.Background(), updateBySetters)).To(Succeed())
// wait for a new commit to be made by the controller
waitForNewHead(localRepo, branch)
})
AfterEach(func() {
Expect(k8sClient.Delete(context.Background(), namespace)).To(Succeed())
})
It("signs the commit with the generated GPG key", func() {
head, _ := localRepo.Head()
commit, err := localRepo.CommitObject(head.Hash())
Expect(err).ToNot(HaveOccurred())
// configure OpenPGP armor encoder
b := bytes.NewBuffer(nil)
w, err := armor.Encode(b, openpgp.PublicKeyType, nil)
Expect(err).ToNot(HaveOccurred())
// serialize public key
err = pgpEntity.Serialize(w)
Expect(err).ToNot(HaveOccurred())
err = w.Close()
Expect(err).ToNot(HaveOccurred())
// verify commit
ent, err := commit.Verify(b.String())
Expect(err).ToNot(HaveOccurred())
Expect(ent.PrimaryKey.Fingerprint).To(Equal(pgpEntity.PrimaryKey.Fingerprint))
})
})
endToEnd := func(impl, proto string) func() {
return func() {
var (

View File

@ -53,6 +53,20 @@ string
</tr>
<tr>
<td>
<code>signingKey</code><br>
<em>
<a href="#image.toolkit.fluxcd.io/v1alpha1.SigningKey">
SigningKey
</a>
</em>
</td>
<td>
<em>(Optional)</em>
<p>SigningKey provides the option to sign commits with a GPG key</p>
</td>
</tr>
<tr>
<td>
<code>messageTemplate</code><br>
<em>
string
@ -502,6 +516,40 @@ starting point, if it doesn&rsquo;t already exist.</p>
</table>
</div>
</div>
<h3 id="image.toolkit.fluxcd.io/v1alpha1.SigningKey">SigningKey
</h3>
<p>
(<em>Appears on:</em>
<a href="#image.toolkit.fluxcd.io/v1alpha1.CommitSpec">CommitSpec</a>)
</p>
<p>SigningKey references a Kubernetes secret that contains a GPG file</p>
<div class="md-typeset__scrollwrap">
<div class="md-typeset__table">
<table>
<thead>
<tr>
<th>Field</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<code>secretRef</code><br>
<em>
<a href="https://godoc.org/github.com/fluxcd/pkg/apis/meta#LocalObjectReference">
github.com/fluxcd/pkg/apis/meta.LocalObjectReference
</a>
</em>
</td>
<td>
<p>SecretRef holds the name to a secret that contains a &lsquo;value&rsquo; key with the GPG file as the value. It must be in the same namespace as the ImageUpdateAutomation.</p>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<h3 id="image.toolkit.fluxcd.io/v1alpha1.UpdateStrategy">UpdateStrategy
</h3>
<p>

1
go.mod
View File

@ -26,6 +26,7 @@ require (
github.com/onsi/gomega v1.10.2
github.com/otiai10/copy v1.2.0
github.com/spf13/pflag v1.0.5
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad
k8s.io/api v0.20.2
k8s.io/apimachinery v0.20.2
k8s.io/client-go v0.20.2