Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enable GPG Signing of Commits #136

Merged
merged 6 commits into from
Mar 30, 2021
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,
squaremo marked this conversation as resolved.
Show resolved Hide resolved
}
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
158 changes: 158 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,162 @@ Images:
})
})

Context("commit signing", func() {
squaremo marked this conversation as resolved.
Show resolved Hide resolved

var localRepo *git.Repository

// generate keypair for signing
pgpEntity, err := openpgp.NewEntity("", "", "", nil)
Expect(err).ToNot(HaveOccurred())
squaremo marked this conversation as resolved.
Show resolved Hide resolved

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)

// 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