Add SigningKey to CommitSpec
Signed-off-by: LWJ <lwjames1996@gmail.com>
This commit is contained in:
parent
daad724ad2
commit
4aa56f1013
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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’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 ‘value’ 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>
|
||||
|
|
Loading…
Reference in New Issue