Skip to content

Commit

Permalink
Merge pull request #136 from LWJ/commit_signing
Browse files Browse the repository at this point in the history
Enable GPG Signing of Commits
  • Loading branch information
squaremo authored Mar 30, 2021
2 parents 3d533a9 + b63b5b2 commit a4f5b8f
Show file tree
Hide file tree
Showing 7 changed files with 307 additions and 3 deletions.
13 changes: 13 additions & 0 deletions api/v1alpha1/imageupdateautomation_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 'git.asc' key
// corresponding to the ASCII Armored file 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
Expand Down
23 changes: 22 additions & 1 deletion api/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -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 'git.asc' key corresponding to the ASCII Armored file
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
Expand Down
44 changes: 42 additions & 2 deletions controllers/imageupdateautomation_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ limitations under the License.
package controllers

import (
"bytes"
"context"
"errors"
"fmt"
"golang.org/x/crypto/openpgp"
"io/ioutil"
"math"
"os"
Expand Down Expand Up @@ -70,6 +72,8 @@ const defaultMessageTemplate = `Update from image update automation`
const repoRefKey = ".spec.gitRepository"
const imagePolicyKey = ".spec.update.imagePolicy"

const signingSecretKey = "git.asc"

// TemplateData is the type of the value given to the commit message
// template.
type TemplateData struct {
Expand Down Expand Up @@ -227,10 +231,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")
Expand Down Expand Up @@ -439,7 +448,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
Expand Down Expand Up @@ -473,13 +482,44 @@ func commitAll(ctx context.Context, repo *gogit.Repository, commit *imagev1.Comm
Email: commit.AuthorEmail,
When: time.Now(),
},
SignKey: ent,
}); err != nil {
return "", err
}

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[signingSecretKey]
if !ok {
return nil, fmt.Errorf("signing key secret '%s' does not contain a 'git.asc' 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
Expand Down
161 changes: 161 additions & 0 deletions controllers/update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -388,6 +390,165 @@ Images:
})
})

Context("commit signing", func() {

var (
localRepo *git.Repository
pgpEntity *openpgp.Entity
)

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)

// generate keypair for signing
pgpEntity, err = openpgp.NewEntity("", "", "", nil)
Expect(err).ToNot(HaveOccurred())

// 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{
"git.asc": b.Bytes(),
},
}
sec.Name = "signing-key-secret-" + randStringRunes(5)
sec.Namespace = namespace.Name
Expect(k8sClient.Create(context.Background(), sec)).To(Succeed())

// 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{
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 (
Expand Down
Loading

0 comments on commit a4f5b8f

Please sign in to comment.