From c53bf7de5fee5917b76fac47b90717490adb6065 Mon Sep 17 00:00:00 2001 From: Hidde Beydals Date: Wed, 20 Mar 2019 11:27:18 +0100 Subject: [PATCH 1/5] Git commit and tag signature verification This feature adds the `--git-verify-signatures` flag to the daemon. When this flag is set the daemon will verify the signatures of the tag and all commits it is working with, ensuring no unauthorized modifications are synchronized. To ensure the daemon always synchronizes a verified state a ratchet mechanism was introduced to the loop. This mechanism moves the sync HEAD to the latest valid revision after repository refreshes. During sync runs this revision gets checked out and is applied on to the cluster. During modification actions, either automated or instructed by fluxctl commands, the HEAD of the working clone is compared to the latest valid revision. If these mismatch the commit is blocked and an error is returned as the daemon can not be sure it is committing on top of a verified state. --- cmd/fluxctl/await.go | 1 + cmd/fluxctl/sync_cmd.go | 13 +- cmd/fluxd/main.go | 27 +- daemon/daemon.go | 95 +++++- daemon/daemon_test.go | 2 +- daemon/errors.go | 18 ++ daemon/loop.go | 405 +++++--------------------- daemon/sync.go | 365 +++++++++++++++++++++++ daemon/{loop_test.go => sync_test.go} | 53 ++-- git/gittest/repo.go | 2 +- git/gittest/repo_test.go | 4 +- git/operations.go | 33 ++- git/repo.go | 28 ++ git/signature.go | 13 + git/working.go | 30 +- 15 files changed, 691 insertions(+), 398 deletions(-) create mode 100644 daemon/sync.go rename daemon/{loop_test.go => sync_test.go} (91%) create mode 100644 git/signature.go diff --git a/cmd/fluxctl/await.go b/cmd/fluxctl/await.go index 0885c641e..681e34691 100644 --- a/cmd/fluxctl/await.go +++ b/cmd/fluxctl/await.go @@ -70,6 +70,7 @@ func awaitJob(ctx context.Context, client api.Server, jobID job.ID) (job.Result, } switch j.StatusString { case job.StatusFailed: + result = j.Result return false, j case job.StatusSucceeded: if j.Err != "" { diff --git a/cmd/fluxctl/sync_cmd.go b/cmd/fluxctl/sync_cmd.go index f909735ec..f13fccf9b 100644 --- a/cmd/fluxctl/sync_cmd.go +++ b/cmd/fluxctl/sync_cmd.go @@ -3,6 +3,7 @@ package main import ( "context" "fmt" + "strings" "github.com/spf13/cobra" @@ -59,13 +60,15 @@ func (opts *syncOpts) RunE(cmd *cobra.Command, args []string) error { return err } result, err := awaitJob(ctx, opts.API, jobID) - if err != nil { + if isUnverifiedHead(err) { + fmt.Fprintf(cmd.OutOrStderr(), "Warning: %s\n", err) + } else if err != nil { fmt.Fprintf(cmd.OutOrStderr(), "Failed to complete sync job (ID %q)\n", jobID) return err } rev := result.Revision[:7] - fmt.Fprintf(cmd.OutOrStderr(), "HEAD of %s is %s\n", gitConfig.Remote.Branch, rev) + fmt.Fprintf(cmd.OutOrStderr(), "Revision of %s to apply is %s\n", gitConfig.Remote.Branch, rev) fmt.Fprintf(cmd.OutOrStderr(), "Waiting for %s to be applied ...\n", rev) err = awaitSync(ctx, opts.API, rev) if err != nil { @@ -74,3 +77,9 @@ func (opts *syncOpts) RunE(cmd *cobra.Command, args []string) error { fmt.Fprintln(cmd.OutOrStderr(), "Done.") return nil } + +func isUnverifiedHead(err error) bool { + return err != nil && + (strings.Contains(err.Error(), "branch HEAD in the git repo is not verified") && + strings.Contains(err.Error(), "last verified commit was")) +} diff --git a/cmd/fluxd/main.go b/cmd/fluxd/main.go index 999601d39..b8ff953d4 100644 --- a/cmd/fluxd/main.go +++ b/cmd/fluxd/main.go @@ -120,8 +120,9 @@ func main() { gitTimeout = fs.Duration("git-timeout", 20*time.Second, "duration after which git operations time out") // GPG commit signing - gitImportGPG = fs.String("git-gpg-key-import", "", "keys at the path given (either a file or a directory) will be imported for use in signing commits") - gitSigningKey = fs.String("git-signing-key", "", "if set, commits will be signed with this GPG key") + gitImportGPG = fs.String("git-gpg-key-import", "", "keys at the path given (either a file or a directory) will be imported for use in signing commits") + gitSigningKey = fs.String("git-signing-key", "", "if set, commits Flux makes will be signed with this GPG key") + gitVerifySignatures = fs.Bool("git-verify-signatures", false, "if set, the signature of commits will be verified before Flux applies them") // syncing syncInterval = fs.Duration("sync-interval", 5*time.Minute, "apply config in git to cluster at least this often, even if there are no new commits") @@ -483,15 +484,15 @@ func main() { gitRemote := git.Remote{URL: *gitURL} gitConfig := git.Config{ - Paths: *gitPath, - Branch: *gitBranch, - SyncTag: *gitSyncTag, - NotesRef: *gitNotesRef, - UserName: *gitUser, - UserEmail: *gitEmail, - SigningKey: *gitSigningKey, - SetAuthor: *gitSetAuthor, - SkipMessage: *gitSkipMessage, + Paths: *gitPath, + Branch: *gitBranch, + SyncTag: *gitSyncTag, + NotesRef: *gitNotesRef, + UserName: *gitUser, + UserEmail: *gitEmail, + SigningKey: *gitSigningKey, + SetAuthor: *gitSetAuthor, + SkipMessage: *gitSkipMessage, } repo := git.NewRepo(gitRemote, git.PollInterval(*gitPollInterval), git.Timeout(*gitTimeout), git.Branch(*gitBranch)) @@ -510,6 +511,7 @@ func main() { "user", *gitUser, "email", *gitEmail, "signing-key", *gitSigningKey, + "verify-signatures", *gitVerifySignatures, "sync-tag", *gitSyncTag, "notes-ref", *gitNotesRef, "set-author", *gitSetAuthor, @@ -534,7 +536,8 @@ func main() { LoopVars: &daemon.LoopVars{ SyncInterval: *syncInterval, RegistryPollInterval: *registryPollInterval, - GitOpTimeout: *gitTimeout, + GitTimeout: *gitTimeout, + GitVerifySignatures: *gitVerifySignatures, }, } diff --git a/daemon/daemon.go b/daemon/daemon.go index a0c6ea5be..3e1095d20 100644 --- a/daemon/daemon.go +++ b/daemon/daemon.go @@ -236,6 +236,9 @@ func (d *Daemon) makeJobFromUpdate(update updateFunc) jobFunc { var result job.Result err := d.WithClone(ctx, func(working *git.Checkout) error { var err error + if err = verifyWorkingRepo(ctx, d.Repo, working, d.GitConfig); d.GitVerifySignatures && err != nil { + return err + } result, err = update(ctx, jobID, working, logger) if err != nil { return err @@ -257,7 +260,7 @@ func (d *Daemon) executeJob(id job.ID, do jobFunc, logger log.Logger) (job.Resul d.JobStatusCache.SetStatus(id, job.Status{StatusString: job.StatusRunning}) result, err := do(ctx, id, logger) if err != nil { - d.JobStatusCache.SetStatus(id, job.Status{StatusString: job.StatusFailed, Err: err.Error()}) + d.JobStatusCache.SetStatus(id, job.Status{StatusString: job.StatusFailed, Err: err.Error(), Result: result}) return result, err } d.JobStatusCache.SetStatus(id, job.Status{StatusString: job.StatusSucceeded, Result: result}) @@ -353,12 +356,25 @@ func (d *Daemon) sync() jobFunc { if err != nil { return result, err } - head, err := d.Repo.Revision(ctx, d.GitConfig.Branch) + head, err := d.Repo.BranchHead(ctx) if err != nil { return result, err } + if d.GitVerifySignatures { + var latestValidRev string + if latestValidRev, _, err = latestValidRevision(ctx, d.Repo, d.GitConfig); err != nil { + return result, err + } else if head != latestValidRev { + result.Revision = latestValidRev + return result, fmt.Errorf( + "The branch HEAD in the git repo is not verified, and fluxd is unable to sync to it. The last verified commit was %.8s. HEAD is %.8s.", + latestValidRev, + head, + ) + } + } result.Revision = head - return result, nil + return result, err } } @@ -751,3 +767,76 @@ func policyEventTypes(u policy.Update) []string { sort.Strings(result) return result } + +// latestValidRevision returns the HEAD of the configured branch if it +// has a valid signature, or the SHA of the latest valid commit it +// could find plus the invalid commit thereafter. +// +// Signature validation happens for commits between the revision of the +// sync tag and the HEAD, after the signature of the sync tag itself +// has been validated, as the branch can not be trusted when the tag +// originates from an unknown source. +// +// In case the signature of the tag can not be verified, or it points +// towards a revision we can not get a commit range for, it returns an +// error. +func latestValidRevision(ctx context.Context, repo *git.Repo, gitConfig git.Config) (string, git.Commit, error) { + var invalidCommit = git.Commit{} + newRevision, err := repo.BranchHead(ctx) + if err != nil { + return "", invalidCommit, err + } + + // Validate tag and retrieve the revision it points to + tagRevision, err := repo.VerifyTag(ctx, gitConfig.SyncTag) + if err != nil && !strings.Contains(err.Error(), "not found.") { + return "", invalidCommit, errors.Wrap(err, "failed to verify signature of sync tag") + } + + var commits []git.Commit + if tagRevision == "" { + commits, err = repo.CommitsBefore(ctx, newRevision) + } else { + // Assure the revision from the tag is a signed and valid commit + if err = repo.VerifyCommit(ctx, tagRevision); err != nil { + return "", invalidCommit, errors.Wrap(err, "failed to verify signature of sync tag revision") + } + commits, err = repo.CommitsBetween(ctx, tagRevision, newRevision) + } + + if err != nil { + return tagRevision, invalidCommit, err + } + + // Loop through commits in ascending order, validating the + // signature of each commit. In case we hit an invalid commit, we + // return the revision of the commit before that, as that one is + // valid. + for i := len(commits) - 1; i >= 0; i-- { + if !commits[i].Signature.Valid() { + if i+1 < len(commits) { + return commits[i+1].Revision, commits[i], nil + } + return tagRevision, commits[i], nil + } + } + + return newRevision, invalidCommit, nil +} + +func verifyWorkingRepo(ctx context.Context, repo *git.Repo, working *git.Checkout, gitConfig git.Config) error { + if latestVerifiedRev, _, err := latestValidRevision(ctx, repo, gitConfig); err != nil { + return err + } else if headRev, err := working.HeadRevision(ctx); err != nil { + return err + } else if headRev != latestVerifiedRev { + return unsignedHeadRevisionError(latestVerifiedRev, headRev) + } + return nil +} + +func isUnknownRevision(err error) bool { + return err != nil && + (strings.Contains(err.Error(), "unknown revision or path not in the working tree.") || + strings.Contains(err.Error(), "bad revision")) +} diff --git a/daemon/daemon_test.go b/daemon/daemon_test.go index 5c1e1bfac..baaa49006 100644 --- a/daemon/daemon_test.go +++ b/daemon/daemon_test.go @@ -745,7 +745,7 @@ func mockDaemon(t *testing.T) (*Daemon, func(), func(), *cluster.Mock, *mockEven JobStatusCache: &job.StatusCache{Size: 100}, EventWriter: events, Logger: logger, - LoopVars: &LoopVars{GitOpTimeout: timeout}, + LoopVars: &LoopVars{GitTimeout: timeout}, } start := func() { diff --git a/daemon/errors.go b/daemon/errors.go index 7291cbb26..32e095d0e 100644 --- a/daemon/errors.go +++ b/daemon/errors.go @@ -67,3 +67,21 @@ daemon if possible: `, } } + +func unsignedHeadRevisionError(latestValidRevision, headRevision string) error { + return &fluxerr.Error{ + Type: fluxerr.User, + Err: fmt.Errorf("HEAD revision is unsigned"), + Help: `HEAD is not a verified commit. + +The branch HEAD in the git repo is not verified, and fluxd is unable to +make a change on top of it. The last verified commit was + + ` + latestValidRevision + ` + +HEAD is + + ` + headRevision + `. +`, + } +} diff --git a/daemon/loop.go b/daemon/loop.go index b8256c086..2eee8c266 100644 --- a/daemon/loop.go +++ b/daemon/loop.go @@ -2,30 +2,20 @@ package daemon import ( "context" - "crypto/sha256" - "encoding/base64" "fmt" - "strings" + "github.com/weaveworks/flux/git" "sync" "time" "github.com/go-kit/kit/log" - "github.com/pkg/errors" - - "github.com/weaveworks/flux" - "github.com/weaveworks/flux/cluster" - "github.com/weaveworks/flux/event" - "github.com/weaveworks/flux/git" fluxmetrics "github.com/weaveworks/flux/metrics" - "github.com/weaveworks/flux/resource" - fluxsync "github.com/weaveworks/flux/sync" - "github.com/weaveworks/flux/update" ) type LoopVars struct { SyncInterval time.Duration RegistryPollInterval time.Duration - GitOpTimeout time.Duration + GitTimeout time.Duration + GitVerifySignatures bool initOnce sync.Once syncSoon chan struct{} @@ -50,7 +40,8 @@ func (d *Daemon) Loop(stop chan struct{}, wg *sync.WaitGroup, logger log.Logger) // available. imagePollTimer := time.NewTimer(d.RegistryPollInterval) - // Keep track of current HEAD, so we can know when to treat a repo + // Keep track of current, verified (if signature verification is + // enabled), HEAD, so we can know when to treat a repo // mirror notification as a change. Otherwise, we'll just sync // every timer tick as well as every mirror refresh. syncHead := "" @@ -61,8 +52,7 @@ func (d *Daemon) Loop(stop chan struct{}, wg *sync.WaitGroup, logger log.Logger) for { var ( - lastKnownSyncTagRev string - warnedAboutSyncTagChange bool + lastKnownSyncTag = &lastKnownSyncTag{logger: logger, syncTag: d.GitConfig.SyncTag} ) select { case <-stop: @@ -86,20 +76,38 @@ func (d *Daemon) Loop(stop chan struct{}, wg *sync.WaitGroup, logger log.Logger) default: } } - if err := d.doSync(logger, &lastKnownSyncTagRev, &warnedAboutSyncTagChange); err != nil { + started := time.Now().UTC() + err := d.Sync(context.Background(), started, syncHead, lastKnownSyncTag) + syncDuration.With( + fluxmetrics.LabelSuccess, fmt.Sprint(err == nil), + ).Observe(time.Since(started).Seconds()) + if err != nil { logger.Log("err", err) } syncTimer.Reset(d.SyncInterval) case <-syncTimer.C: d.AskForSync() case <-d.Repo.C: - ctx, cancel := context.WithTimeout(context.Background(), d.GitOpTimeout) - newSyncHead, err := d.Repo.Revision(ctx, d.GitConfig.Branch) + var newSyncHead string + var invalidCommit git.Commit + var err error + + ctx, cancel := context.WithTimeout(context.Background(), d.GitTimeout) + if d.GitVerifySignatures { + newSyncHead, invalidCommit, err = latestValidRevision(ctx, d.Repo, d.GitConfig) + } else { + newSyncHead, err = d.Repo.BranchHead(ctx) + } cancel() + if err != nil { logger.Log("url", d.Repo.Origin().URL, "err", err) continue } + if invalidCommit.Revision != "" { + logger.Log("err", "found invalid GPG signature for commit", "revision", invalidCommit.Revision, "key", invalidCommit.Signature.Key) + } + logger.Log("event", "refreshed", "url", d.Repo.Origin().URL, "branch", d.GitConfig.Branch, "HEAD", newSyncHead) if newSyncHead != syncHead { syncHead = newSyncHead @@ -121,7 +129,7 @@ func (d *Daemon) Loop(stop chan struct{}, wg *sync.WaitGroup, logger log.Logger) jobLogger.Log("state", "done", "success", "false", "err", err) } else { jobLogger.Log("state", "done", "success", "true") - ctx, cancel := context.WithTimeout(context.Background(), d.GitOpTimeout) + ctx, cancel := context.WithTimeout(context.Background(), d.GitTimeout) err := d.Repo.Refresh(ctx) if err != nil { logger.Log("err", err) @@ -150,318 +158,51 @@ func (d *LoopVars) AskForImagePoll() { } } -// -- extra bits the loop needs - -func (d *Daemon) doSync(logger log.Logger, lastKnownSyncTagRev *string, warnedAboutSyncTagChange *bool) (retErr error) { - started := time.Now().UTC() - defer func() { - syncDuration.With( - fluxmetrics.LabelSuccess, fmt.Sprint(retErr == nil), - ).Observe(time.Since(started).Seconds()) - }() - - syncSetName := makeGitConfigHash(d.Repo.Origin(), d.GitConfig) - - // We don't care how long this takes overall, only about not - // getting bogged down in certain operations, so use an - // undeadlined context in general. - ctx := context.Background() - - // checkout a working clone so we can mess around with tags later - var working *git.Checkout - { - var err error - ctx, cancel := context.WithTimeout(ctx, d.GitOpTimeout) - defer cancel() - working, err = d.Repo.Clone(ctx, d.GitConfig) - if err != nil { - return err - } - defer working.Clean() - } - - // For comparison later. - oldTagRev, err := working.SyncRevision(ctx) - if err != nil && !isUnknownRevision(err) { - return err - } - // Check if something other than the current instance of fluxd changed the sync tag. - // This is likely to be caused by another fluxd instance using the same tag. - // Having multiple instances fighting for the same tag can lead to fluxd missing manifest changes. - if *lastKnownSyncTagRev != "" && oldTagRev != *lastKnownSyncTagRev && !*warnedAboutSyncTagChange { - logger.Log("warning", - "detected external change in git sync tag; the sync tag should not be shared by fluxd instances") - *warnedAboutSyncTagChange = true - } - - newTagRev, err := working.HeadRevision(ctx) - if err != nil { - return err - } - - // Get a map of all resources defined in the repo - allResources, err := d.Manifests.LoadManifests(working.Dir(), working.ManifestDirs()) - if err != nil { - return errors.Wrap(err, "loading resources from repo") - } - - var resourceErrors []event.ResourceError - if err := fluxsync.Sync(syncSetName, allResources, d.Cluster); err != nil { - switch syncerr := err.(type) { - case cluster.SyncError: - logger.Log("err", err) - for _, e := range syncerr { - resourceErrors = append(resourceErrors, event.ResourceError{ - ID: e.ResourceID, - Path: e.Source, - Error: e.Error.Error(), - }) - } - default: - return err - } - } - - // update notes and emit events for applied commits - - var initialSync bool - var commits []git.Commit - { - var err error - ctx, cancel := context.WithTimeout(ctx, d.GitOpTimeout) - if oldTagRev != "" { - commits, err = d.Repo.CommitsBetween(ctx, oldTagRev, newTagRev, d.GitConfig.Paths...) - } else { - initialSync = true - commits, err = d.Repo.CommitsBefore(ctx, newTagRev, d.GitConfig.Paths...) - } - cancel() - if err != nil { - return err - } - } - - // Figure out which workload IDs changed in this release - changedResources := map[string]resource.Resource{} - - if initialSync { - // no synctag, We are syncing everything from scratch - changedResources = allResources - } else { - ctx, cancel := context.WithTimeout(ctx, d.GitOpTimeout) - changedFiles, err := working.ChangedFiles(ctx, oldTagRev) - if err == nil && len(changedFiles) > 0 { - // We had some changed files, we're syncing a diff - // FIXME(michael): this won't be accurate when a file can have more than one resource - changedResources, err = d.Manifests.LoadManifests(working.Dir(), changedFiles) - } - cancel() - if err != nil { - return errors.Wrap(err, "loading resources from repo") - } - } - - workloadIDs := flux.ResourceIDSet{} - for _, r := range changedResources { - workloadIDs.Add([]flux.ResourceID{r.ResourceID()}) - } - - var notes map[string]struct{} - { - ctx, cancel := context.WithTimeout(ctx, d.GitOpTimeout) - notes, err = working.NoteRevList(ctx) - cancel() - if err != nil { - return errors.Wrap(err, "loading notes from repo") - } - } - - // Collect any events that come from notes attached to the commits - // we just synced. While we're doing this, keep track of what - // other things this sync includes e.g., releases and - // autoreleases, that we're already posting as events, so upstream - // can skip the sync event if it wants to. - includes := make(map[string]bool) - if len(commits) > 0 { - var noteEvents []event.Event - - // Find notes in revisions. - for i := len(commits) - 1; i >= 0; i-- { - if _, ok := notes[commits[i].Revision]; !ok { - includes[event.NoneOfTheAbove] = true - continue - } - ctx, cancel := context.WithTimeout(ctx, d.GitOpTimeout) - var n note - ok, err := working.GetNote(ctx, commits[i].Revision, &n) - cancel() - if err != nil { - return errors.Wrap(err, "loading notes from repo") - } - if !ok { - includes[event.NoneOfTheAbove] = true - continue - } - - // If this is the first sync, we should expect no notes, - // since this is supposedly the first time we're seeing - // the repo. But there are circumstances in which we can - // nonetheless see notes -- if the tag was deleted from - // the upstream repo, or if this accidentally has the same - // notes ref as another daemon using the same repo (but a - // different tag). Either way, we don't want to report any - // notes on an initial sync, since they (most likely) - // don't belong to us. - if initialSync { - logger.Log("warning", "no notes expected on initial sync; this repo may be in use by another fluxd") - break - } - - // Interpret some notes as events to send to the upstream - switch n.Spec.Type { - case update.Containers: - spec := n.Spec.Spec.(update.ReleaseContainersSpec) - noteEvents = append(noteEvents, event.Event{ - ServiceIDs: n.Result.AffectedResources(), - Type: event.EventRelease, - StartedAt: started, - EndedAt: time.Now().UTC(), - LogLevel: event.LogLevelInfo, - Metadata: &event.ReleaseEventMetadata{ - ReleaseEventCommon: event.ReleaseEventCommon{ - Revision: commits[i].Revision, - Result: n.Result, - Error: n.Result.Error(), - }, - Spec: event.ReleaseSpec{ - Type: event.ReleaseContainersSpecType, - ReleaseContainersSpec: &spec, - }, - Cause: n.Spec.Cause, - }, - }) - includes[event.EventRelease] = true - case update.Images: - spec := n.Spec.Spec.(update.ReleaseImageSpec) - noteEvents = append(noteEvents, event.Event{ - ServiceIDs: n.Result.AffectedResources(), - Type: event.EventRelease, - StartedAt: started, - EndedAt: time.Now().UTC(), - LogLevel: event.LogLevelInfo, - Metadata: &event.ReleaseEventMetadata{ - ReleaseEventCommon: event.ReleaseEventCommon{ - Revision: commits[i].Revision, - Result: n.Result, - Error: n.Result.Error(), - }, - Spec: event.ReleaseSpec{ - Type: event.ReleaseImageSpecType, - ReleaseImageSpec: &spec, - }, - Cause: n.Spec.Cause, - }, - }) - includes[event.EventRelease] = true - case update.Auto: - spec := n.Spec.Spec.(update.Automated) - noteEvents = append(noteEvents, event.Event{ - ServiceIDs: n.Result.AffectedResources(), - Type: event.EventAutoRelease, - StartedAt: started, - EndedAt: time.Now().UTC(), - LogLevel: event.LogLevelInfo, - Metadata: &event.AutoReleaseEventMetadata{ - ReleaseEventCommon: event.ReleaseEventCommon{ - Revision: commits[i].Revision, - Result: n.Result, - Error: n.Result.Error(), - }, - Spec: spec, - }, - }) - includes[event.EventAutoRelease] = true - case update.Policy: - // Use this to mean any change to policy - includes[event.EventUpdatePolicy] = true - default: - // Presume it's not something we're otherwise sending - // as an event - includes[event.NoneOfTheAbove] = true - } - } - - cs := make([]event.Commit, len(commits)) - for i, c := range commits { - cs[i].Revision = c.Revision - cs[i].Message = c.Message - } - if err = d.LogEvent(event.Event{ - ServiceIDs: workloadIDs.ToSlice(), - Type: event.EventSync, - StartedAt: started, - EndedAt: started, - LogLevel: event.LogLevelInfo, - Metadata: &event.SyncEventMetadata{ - Commits: cs, - InitialSync: initialSync, - Includes: includes, - Errors: resourceErrors, - }, - }); err != nil { - logger.Log("err", err) - // Abort early to ensure at least once delivery of events - return err - } - - for _, event := range noteEvents { - if err = d.LogEvent(event); err != nil { - logger.Log("err", err) - // Abort early to ensure at least once delivery of events - return err - } - } - } - - // Move the tag and push it so we know how far we've gotten. - if oldTagRev != newTagRev { - { - ctx, cancel := context.WithTimeout(ctx, d.GitOpTimeout) - tagAction := git.TagAction{ - Revision: newTagRev, - Message: "Sync pointer", - } - err := working.MoveSyncTagAndPush(ctx, tagAction) - cancel() - if err != nil { - return err - } - *lastKnownSyncTagRev = newTagRev - } - logger.Log("tag", d.GitConfig.SyncTag, "old", oldTagRev, "new", newTagRev) - { - ctx, cancel := context.WithTimeout(ctx, d.GitOpTimeout) - err := d.Repo.Refresh(ctx) - cancel() - return err - } - } - return nil -} - -func isUnknownRevision(err error) bool { - return err != nil && - (strings.Contains(err.Error(), "unknown revision or path not in the working tree.") || - strings.Contains(err.Error(), "bad revision")) +// -- internals to keep track of sync tag state +type lastKnownSyncTag struct { + logger log.Logger + syncTag string + revision string + warnedAboutChange bool } -func makeGitConfigHash(remote git.Remote, conf git.Config) string { - urlbit := remote.SafeURL() - pathshash := sha256.New() - pathshash.Write([]byte(urlbit)) - pathshash.Write([]byte(conf.Branch)) - for _, path := range conf.Paths { - pathshash.Write([]byte(path)) - } - return base64.RawURLEncoding.EncodeToString(pathshash.Sum(nil)) +// SetRevision updates the sync tag revision in git _and_ the +// in-memory revision, if it has changed. In addition, it validates +// if the in-memory revision matches the old revision from git before +// making the update, to notify a user about multiple Flux daemons +// using the same tag. +func (s *lastKnownSyncTag) SetRevision(ctx context.Context, working *git.Checkout, timeout time.Duration, + oldRev, newRev string) (bool, error) { + // Check if something other than the current instance of fluxd + // changed the sync tag. This is likely caused by another instance + // using the same tag. Having multiple instances fight for the same + // tag can lead to fluxd missing manifest changes. + if s.revision != "" && oldRev != s.revision && !s.warnedAboutChange { + s.logger.Log("warning", + "detected external change in git sync tag; the sync tag should not be shared by fluxd instances", + "tag", s.syncTag) + s.warnedAboutChange = true + } + + // Did it actually change? + if s.revision == newRev { + return false, nil + } + + // Update the sync tag revision in git + tagAction := git.TagAction{ + Revision: newRev, + Message: "Sync pointer", + } + ctx, cancel := context.WithTimeout(ctx, timeout) + if err := working.MoveSyncTagAndPush(ctx, tagAction); err != nil { + return false, err + } + cancel() + + // Update in-memory revision + s.revision = newRev + + s.logger.Log("tag", s.syncTag, "old", oldRev, "new", newRev) + return true, nil } diff --git a/daemon/sync.go b/daemon/sync.go new file mode 100644 index 000000000..670c0536c --- /dev/null +++ b/daemon/sync.go @@ -0,0 +1,365 @@ +package daemon + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "github.com/go-kit/kit/log" + "github.com/pkg/errors" + "time" + + "github.com/weaveworks/flux" + "github.com/weaveworks/flux/cluster" + "github.com/weaveworks/flux/event" + "github.com/weaveworks/flux/git" + "github.com/weaveworks/flux/resource" + fluxsync "github.com/weaveworks/flux/sync" + "github.com/weaveworks/flux/update" +) + +type syncTag interface { + SetRevision(ctx context.Context, working *git.Checkout, timeout time.Duration, oldRev, newRev string) (bool, error) +} + +type eventLogger interface { + LogEvent(e event.Event) error +} + +type changeSet struct { + commits []git.Commit + oldTagRev string + newTagRev string + initialSync bool +} + +// Sync starts the synchronization of the cluster with git. +func (d *Daemon) Sync(ctx context.Context, started time.Time, revision string, syncTag syncTag) error { + // Checkout a working clone used for this sync + ctxt, cancel := context.WithTimeout(ctx, d.GitTimeout) + working, err := d.Repo.Clone(ctxt, d.GitConfig) + if err != nil { + return err + } + cancel() + defer working.Clean() + + // Ensure we are syncing the given revision + if err := working.Checkout(ctx, revision); err != nil { + return err + } + + // Retrieve change set of commits we need to sync + c, err := getChangeSet(ctx, working, d.Repo, d.GitTimeout, d.GitConfig.Paths) + if err != nil { + return err + } + + // Run actual sync of resources on cluster + syncSetName := makeGitConfigHash(d.Repo.Origin(), d.GitConfig) + resources, resourceErrors, err := doSync(d.Manifests, working, d.Cluster, syncSetName, d.Logger) + if err != nil { + return err + } + + // Determine what resources changed during the sync + changedResources, err := getChangedResources(ctx, c, d.GitTimeout, working, d.Manifests, resources) + serviceIDs := flux.ResourceIDSet{} + for _, r := range changedResources { + serviceIDs.Add([]flux.ResourceID{r.ResourceID()}) + } + + // Retrieve git notes and collect events from them + notes, err := getNotes(ctx, d.GitTimeout, working) + if err != nil { + return err + } + noteEvents, includesEvents, err := collectNoteEvents(ctx, c, notes, d.GitTimeout, working, started, d.Logger) + if err != nil { + return err + } + + // Report all synced commits + if err := logCommitEvent(d, c, serviceIDs, started, includesEvents, resourceErrors, d.Logger); err != nil { + return err + } + + // Report all collected events + for _, event := range noteEvents { + if err = d.LogEvent(event); err != nil { + d.Logger.Log("err", err) + // Abort early to ensure at least once delivery of events + return err + } + } + + // Move sync tag + if ok, err := syncTag.SetRevision(ctx, working, d.GitTimeout, c.oldTagRev, c.newTagRev); err != nil { + return err + } else if !ok { + return nil + } + + err = refresh(ctx, d.GitTimeout, d.Repo) + return err +} + +// getChangeSet returns the change set of commits for this sync, +// including the revision range and if it is an initial sync. +func getChangeSet(ctx context.Context, working *git.Checkout, repo *git.Repo, timeout time.Duration, + paths []string) (changeSet, error) { + var c changeSet + var err error + + c.oldTagRev, err = working.SyncRevision(ctx) + if err != nil && !isUnknownRevision(err) { + return c, err + } + c.newTagRev, err = working.HeadRevision(ctx) + if err != nil { + return c, err + } + + ctx, cancel := context.WithTimeout(ctx, timeout) + if c.oldTagRev != "" { + c.commits, err = repo.CommitsBetween(ctx, c.oldTagRev, c.newTagRev, paths...) + } else { + c.initialSync = true + c.commits, err = repo.CommitsBefore(ctx, c.newTagRev, paths...) + } + cancel() + + return c, err +} + +// doSync runs the actual sync of workloads on the cluster. It returns +// a map with all resources it applied and sync errors it encountered. +func doSync(manifests cluster.Manifests, working *git.Checkout, clus cluster.Cluster, syncSetName string, + logger log.Logger) (map[string]resource.Resource, []event.ResourceError, error) { + resources, err := manifests.LoadManifests(working.Dir(), working.ManifestDirs()) + if err != nil { + return nil, nil, errors.Wrap(err, "loading resources from repo") + } + + var resourceErrors []event.ResourceError + if err := fluxsync.Sync(syncSetName, resources, clus); err != nil { + switch syncerr := err.(type) { + case cluster.SyncError: + logger.Log("err", err) + for _, e := range syncerr { + resourceErrors = append(resourceErrors, event.ResourceError{ + ID: e.ResourceID, + Path: e.Source, + Error: e.Error.Error(), + }) + } + default: + return nil, nil, err + } + } + return resources, resourceErrors, nil +} + +// getChangedResources calculates what resources are modified during +// this sync. +func getChangedResources(ctx context.Context, c changeSet, timeout time.Duration, working *git.Checkout, + manifests cluster.Manifests, resources map[string]resource.Resource) (map[string]resource.Resource, error) { + if c.initialSync { + return resources, nil + } + + ctx, cancel := context.WithTimeout(ctx, timeout) + changedFiles, err := working.ChangedFiles(ctx, c.oldTagRev) + if err == nil && len(changedFiles) > 0 { + // We had some changed files, we're syncing a diff + // FIXME(michael): this won't be accurate when a file can have more than one resource + resources, err = manifests.LoadManifests(working.Dir(), changedFiles) + } + cancel() + if err != nil { + return nil, errors.Wrap(err, "loading resources from repo") + } + return resources, nil +} + +// getNotes retrieves the git notes from the working clone. +func getNotes(ctx context.Context, timeout time.Duration, working *git.Checkout) (map[string]struct{}, error) { + ctx, cancel := context.WithTimeout(ctx, timeout) + notes, err := working.NoteRevList(ctx) + cancel() + if err != nil { + return nil, errors.Wrap(err, "loading notes from repo") + } + return notes, nil +} + +// collectNoteEvents collects any events that come from notes attached +// to the commits we just synced. While we're doing this, keep track +// of what other things this sync includes e.g., releases and +// autoreleases, that we're already posting as events, so upstream +// can skip the sync event if it wants to. +func collectNoteEvents(ctx context.Context, c changeSet, notes map[string]struct{}, timeout time.Duration, + working *git.Checkout, started time.Time, logger log.Logger) ([]event.Event, map[string]bool, error) { + if len(c.commits) == 0 { + return nil, nil, nil + } + + var noteEvents []event.Event + var eventTypes = make(map[string]bool) + + // Find notes in revisions. + for i := len(c.commits) - 1; i >= 0; i-- { + if _, ok := notes[c.commits[i].Revision]; !ok { + eventTypes[event.NoneOfTheAbove] = true + continue + } + var n note + ctx, cancel := context.WithTimeout(ctx, timeout) + ok, err := working.GetNote(ctx, c.commits[i].Revision, &n) + cancel() + if err != nil { + return nil, nil, errors.Wrap(err, "loading notes from repo") + } + if !ok { + eventTypes[event.NoneOfTheAbove] = true + continue + } + + // If this is the first sync, we should expect no notes, + // since this is supposedly the first time we're seeing + // the repo. But there are circumstances in which we can + // nonetheless see notes -- if the tag was deleted from + // the upstream repo, or if this accidentally has the same + // notes ref as another daemon using the same repo (but a + // different tag). Either way, we don't want to report any + // notes on an initial sync, since they (most likely) + // don't belong to us. + if c.initialSync { + logger.Log("warning", "no notes expected on initial sync; this repo may be in use by another fluxd") + return noteEvents, eventTypes, nil + } + + // Interpret some notes as events to send to the upstream + switch n.Spec.Type { + case update.Containers: + spec := n.Spec.Spec.(update.ReleaseContainersSpec) + noteEvents = append(noteEvents, event.Event{ + ServiceIDs: n.Result.AffectedResources(), + Type: event.EventRelease, + StartedAt: started, + EndedAt: time.Now().UTC(), + LogLevel: event.LogLevelInfo, + Metadata: &event.ReleaseEventMetadata{ + ReleaseEventCommon: event.ReleaseEventCommon{ + Revision: c.commits[i].Revision, + Result: n.Result, + Error: n.Result.Error(), + }, + Spec: event.ReleaseSpec{ + Type: event.ReleaseContainersSpecType, + ReleaseContainersSpec: &spec, + }, + Cause: n.Spec.Cause, + }, + }) + eventTypes[event.EventRelease] = true + case update.Images: + spec := n.Spec.Spec.(update.ReleaseImageSpec) + noteEvents = append(noteEvents, event.Event{ + ServiceIDs: n.Result.AffectedResources(), + Type: event.EventRelease, + StartedAt: started, + EndedAt: time.Now().UTC(), + LogLevel: event.LogLevelInfo, + Metadata: &event.ReleaseEventMetadata{ + ReleaseEventCommon: event.ReleaseEventCommon{ + Revision: c.commits[i].Revision, + Result: n.Result, + Error: n.Result.Error(), + }, + Spec: event.ReleaseSpec{ + Type: event.ReleaseImageSpecType, + ReleaseImageSpec: &spec, + }, + Cause: n.Spec.Cause, + }, + }) + eventTypes[event.EventRelease] = true + case update.Auto: + spec := n.Spec.Spec.(update.Automated) + noteEvents = append(noteEvents, event.Event{ + ServiceIDs: n.Result.AffectedResources(), + Type: event.EventAutoRelease, + StartedAt: started, + EndedAt: time.Now().UTC(), + LogLevel: event.LogLevelInfo, + Metadata: &event.AutoReleaseEventMetadata{ + ReleaseEventCommon: event.ReleaseEventCommon{ + Revision: c.commits[i].Revision, + Result: n.Result, + Error: n.Result.Error(), + }, + Spec: spec, + }, + }) + eventTypes[event.EventAutoRelease] = true + case update.Policy: + // Use this to mean any change to policy + eventTypes[event.EventUpdatePolicy] = true + default: + // Presume it's not something we're otherwise sending + // as an event + eventTypes[event.NoneOfTheAbove] = true + } + } + return noteEvents, eventTypes, nil +} + +// logCommitEvent reports all synced commits to the upstream. +func logCommitEvent(el eventLogger, c changeSet, serviceIDs flux.ResourceIDSet, started time.Time, + includesEvents map[string]bool, resourceErrors []event.ResourceError, logger log.Logger) error { + if len(c.commits) == 0 { + return nil + } + cs := make([]event.Commit, len(c.commits)) + for i, ci := range c.commits { + cs[i].Revision = ci.Revision + cs[i].Message = ci.Message + } + if err := el.LogEvent(event.Event{ + ServiceIDs: serviceIDs.ToSlice(), + Type: event.EventSync, + StartedAt: started, + EndedAt: started, + LogLevel: event.LogLevelInfo, + Metadata: &event.SyncEventMetadata{ + Commits: cs, + InitialSync: c.initialSync, + Includes: includesEvents, + Errors: resourceErrors, + }, + }); err != nil { + logger.Log("err", err) + return err + } + return nil +} + +// refresh refreshes the repository, notifying the daemon we have a new +// sync head. +func refresh(ctx context.Context, timeout time.Duration, repo *git.Repo) error { + ctx, cancel := context.WithTimeout(ctx, timeout) + err := repo.Refresh(ctx) + cancel() + return err +} + +func makeGitConfigHash(remote git.Remote, conf git.Config) string { + urlbit := remote.SafeURL() + pathshash := sha256.New() + pathshash.Write([]byte(urlbit)) + pathshash.Write([]byte(conf.Branch)) + for _, path := range conf.Paths { + pathshash.Write([]byte(path)) + } + return base64.RawURLEncoding.EncodeToString(pathshash.Sum(nil)) +} diff --git a/daemon/loop_test.go b/daemon/sync_test.go similarity index 91% rename from daemon/loop_test.go rename to daemon/sync_test.go index 0a7db386f..f85ba4d68 100644 --- a/daemon/loop_test.go +++ b/daemon/sync_test.go @@ -1,7 +1,6 @@ package daemon import ( - "io/ioutil" "os" "reflect" "strings" @@ -73,7 +72,7 @@ func daemon(t *testing.T) (*Daemon, func()) { JobStatusCache: &job.StatusCache{Size: 100}, EventWriter: events, Logger: log.NewLogfmtLogger(os.Stdout), - LoopVars: &LoopVars{GitOpTimeout: 5 * time.Second}, + LoopVars: &LoopVars{GitTimeout: timeout}, } return d, func() { close(shutdown) @@ -85,8 +84,6 @@ func daemon(t *testing.T) (*Daemon, func()) { } func TestPullAndSync_InitialSync(t *testing.T) { - // No tag - // No notes d, cleanup := daemon(t) defer cleanup() @@ -102,12 +99,17 @@ func TestPullAndSync_InitialSync(t *testing.T) { syncDef = &def return nil } - var ( - logger = log.NewLogfmtLogger(ioutil.Discard) - lastKnownSyncTagRev string - warnedAboutSyncTagChange bool - ) - d.doSync(logger, &lastKnownSyncTagRev, &warnedAboutSyncTagChange) + + ctx := context.Background() + head, err := d.Repo.BranchHead(ctx) + if err != nil { + t.Fatal(err) + } + syncTag := lastKnownSyncTag{logger: d.Logger, syncTag: d.GitConfig.SyncTag} + + if err := d.Sync(ctx, time.Now().UTC(), head, &syncTag); err != nil { + t.Error(err) + } // It applies everything if syncCalled != 1 { @@ -131,6 +133,7 @@ func TestPullAndSync_InitialSync(t *testing.T) { t.Errorf("Unexpected event workload ids: %#v, expected: %#v", gotResourceIDs, expectedResourceIDs) } } + // It creates the tag at HEAD if err := d.Repo.Refresh(context.Background()); err != nil { t.Errorf("pulling sync tag: %v", err) @@ -177,12 +180,14 @@ func TestDoSync_NoNewCommits(t *testing.T) { syncDef = &def return nil } - var ( - logger = log.NewLogfmtLogger(ioutil.Discard) - lastKnownSyncTagRev string - warnedAboutSyncTagChange bool - ) - if err := d.doSync(logger, &lastKnownSyncTagRev, &warnedAboutSyncTagChange); err != nil { + + head, err := d.Repo.BranchHead(ctx) + if err != nil { + t.Fatal(err) + } + syncTag := lastKnownSyncTag{logger: d.Logger, syncTag: d.GitConfig.SyncTag} + + if err := d.Sync(ctx, time.Now().UTC(), head, &syncTag); err != nil { t.Error(err) } @@ -277,12 +282,16 @@ func TestDoSync_WithNewCommit(t *testing.T) { syncDef = &def return nil } - var ( - logger = log.NewLogfmtLogger(ioutil.Discard) - lastKnownSyncTagRev string - warnedAboutSyncTagChange bool - ) - d.doSync(logger, &lastKnownSyncTagRev, &warnedAboutSyncTagChange) + + head, err := d.Repo.BranchHead(ctx) + if err != nil { + t.Fatal(err) + } + syncTag := lastKnownSyncTag{logger: d.Logger, syncTag: d.GitConfig.SyncTag} + + if err := d.Sync(ctx, time.Now().UTC(), head, &syncTag); err != nil { + t.Error(err) + } // It applies everything if syncCalled != 1 { diff --git a/git/gittest/repo.go b/git/gittest/repo.go index 525b7333c..5c4dd6ffb 100644 --- a/git/gittest/repo.go +++ b/git/gittest/repo.go @@ -55,7 +55,7 @@ func Repo(t *testing.T) (*git.Repo, func()) { mirror := git.NewRepo(git.Remote{ URL: "file://" + gitDir, - }) + }, git.Branch("master")) return mirror, func() { mirror.Clean() cleanup() diff --git a/git/gittest/repo_test.go b/git/gittest/repo_test.go index 2f997078a..29572298d 100644 --- a/git/gittest/repo_test.go +++ b/git/gittest/repo_test.go @@ -112,7 +112,7 @@ func TestSignedCommit(t *testing.T) { t.Fatal("expected at least one commit") } expectedKey := signingKey[len(signingKey)-16:] - foundKey := commits[0].SigningKey[len(commits[0].SigningKey)-16:] + foundKey := commits[0].Signature.Key[len(commits[0].Signature.Key)-16:] if expectedKey != foundKey { t.Errorf(`expected commit signing key to be: %s @@ -144,7 +144,7 @@ func TestSignedTag(t *testing.T) { t.Fatal(err) } - err := checkout.VerifySyncTag(ctx) + _, err := checkout.VerifySyncTag(ctx) if err != nil { t.Fatal(err) } diff --git a/git/operations.go b/git/operations.go index 8f841377d..a49cb71a5 100644 --- a/git/operations.go +++ b/git/operations.go @@ -212,7 +212,7 @@ func refRevision(ctx context.Context, workingDir, ref string) (string, error) { // Return the revisions and one-line log commit messages func onelinelog(ctx context.Context, workingDir, refspec string, subdirs []string) ([]Commit, error) { out := &bytes.Buffer{} - args := []string{"log", "--pretty=format:%GK|%H|%s", refspec} + args := []string{"log", "--pretty=format:%GK|%G?|%H|%s", refspec} args = append(args, "--") if len(subdirs) > 0 { args = append(args, subdirs...) @@ -229,10 +229,13 @@ func splitLog(s string) ([]Commit, error) { lines := splitList(s) commits := make([]Commit, len(lines)) for i, m := range lines { - parts := strings.SplitN(m, "|", 3) - commits[i].SigningKey = parts[0] - commits[i].Revision = parts[1] - commits[i].Message = parts[2] + parts := strings.SplitN(m, "|", 4) + commits[i].Signature = Signature{ + Key: parts[0], + Status: parts[1], + } + commits[i].Revision = parts[2] + commits[i].Message = parts[3] } return commits, nil } @@ -263,11 +266,21 @@ func moveTagAndPush(ctx context.Context, workingDir, tag, upstream string, tagAc return nil } -func verifyTag(ctx context.Context, workingDir, tag string) error { - var env []string - args := []string{"verify-tag", tag} - if err := execGitCmd(ctx, args, gitCmdConfig{dir: workingDir, env: env}); err != nil { - return errors.Wrap(err, "verifying tag "+tag) +// Verify tag signature and return the revision it points to +func verifyTag(ctx context.Context, workingDir, tag string) (string, error) { + out := &bytes.Buffer{} + args := []string{"verify-tag", "--format", "%(object)", tag} + if err := execGitCmd(ctx, args, gitCmdConfig{dir: workingDir, out: out}); err != nil { + return "", errors.Wrap(err, "verifying tag "+tag) + } + return strings.TrimSpace(out.String()), nil +} + +// Verify commit signature +func verifyCommit(ctx context.Context, workingDir, commit string) error { + args := []string{"verify-commit", commit} + if err := execGitCmd(ctx, args, gitCmdConfig{dir: workingDir}); err != nil { + return fmt.Errorf("failed to verify commit %s", commit) } return nil } diff --git a/git/repo.go b/git/repo.go index f9ac0f611..51b837ba5 100644 --- a/git/repo.go +++ b/git/repo.go @@ -208,6 +208,16 @@ func (r *Repo) Revision(ctx context.Context, ref string) (string, error) { return refRevision(ctx, r.dir, ref) } +// BranchHead returns the HEAD revision (SHA1) of the configured branch +func (r *Repo) BranchHead(ctx context.Context) (string, error) { + r.mu.RLock() + defer r.mu.RUnlock() + if err := r.errorIfNotReady(); err != nil { + return "", err + } + return refRevision(ctx, r.dir, "heads/"+r.branch) +} + func (r *Repo) CommitsBefore(ctx context.Context, ref string, paths ...string) ([]Commit, error) { r.mu.RLock() defer r.mu.RUnlock() @@ -226,6 +236,24 @@ func (r *Repo) CommitsBetween(ctx context.Context, ref1, ref2 string, paths ...s return onelinelog(ctx, r.dir, ref1+".."+ref2, paths) } +func (r *Repo) VerifyTag(ctx context.Context, tag string) (string, error) { + r.mu.RLock() + defer r.mu.RUnlock() + if err := r.errorIfNotReady(); err != nil { + return "", err + } + return verifyTag(ctx, r.dir, tag) +} + +func (r *Repo) VerifyCommit(ctx context.Context, commit string) error { + r.mu.RLock() + defer r.mu.RUnlock() + if err := r.errorIfNotReady(); err != nil { + return err + } + return verifyCommit(ctx, r.dir, commit) +} + // step attempts to advance the repo state machine, and returns `true` // if it has made progress, `false` otherwise. func (r *Repo) step(bg context.Context) bool { diff --git a/git/signature.go b/git/signature.go new file mode 100644 index 000000000..40a7ce7f8 --- /dev/null +++ b/git/signature.go @@ -0,0 +1,13 @@ +package git + +// Signature holds information about a GPG signature. +type Signature struct { + Key string + Status string +} + +// Valid returns true if the signature is _G_ood (valid). +// https://github.com/git/git/blob/56d268bafff7538f82c01d3c9c07bdc54b2993b1/Documentation/pretty-formats.txt#L146-L153 +func (s *Signature) Valid() bool { + return s.Status == "G" +} diff --git a/git/working.go b/git/working.go index c0162af5d..7d03d4a15 100644 --- a/git/working.go +++ b/git/working.go @@ -14,15 +14,15 @@ var ( // Config holds some values we use when working in the working clone of // a repo. type Config struct { - Branch string // branch we're syncing to - Paths []string // paths within the repo containing files we care about - SyncTag string - NotesRef string - UserName string - UserEmail string - SigningKey string - SetAuthor bool - SkipMessage string + Branch string // branch we're syncing to + Paths []string // paths within the repo containing files we care about + SyncTag string + NotesRef string + UserName string + UserEmail string + SigningKey string + SetAuthor bool + SkipMessage string } // Checkout is a local working clone of the remote repo. It is @@ -36,9 +36,9 @@ type Checkout struct { } type Commit struct { - SigningKey string - Revision string - Message string + Signature Signature + Revision string + Message string } // CommitAction - struct holding commit information @@ -184,7 +184,7 @@ func (c *Checkout) MoveSyncTagAndPush(ctx context.Context, tagAction TagAction) return moveTagAndPush(ctx, c.dir, c.config.SyncTag, c.upstream.URL, tagAction) } -func (c *Checkout) VerifySyncTag(ctx context.Context) error { +func (c *Checkout) VerifySyncTag(ctx context.Context) (string, error) { return verifyTag(ctx, c.dir, c.config.SyncTag) } @@ -202,3 +202,7 @@ func (c *Checkout) ChangedFiles(ctx context.Context, ref string) ([]string, erro func (c *Checkout) NoteRevList(ctx context.Context) (map[string]struct{}, error) { return noteRevList(ctx, c.dir, c.realNotesRef) } + +func (c *Checkout) Checkout(ctx context.Context, rev string) error { + return checkout(ctx, c.dir, rev) +} From 0aff945e2d2b2c95d86b865675d66a540dc54902 Mon Sep 17 00:00:00 2001 From: Hidde Beydals Date: Wed, 20 Mar 2019 19:28:49 +0100 Subject: [PATCH 2/5] Trust imported keys when verifying signatures This is required when signature verification is enabled as git will otherwise give an 'U' status back for the signature (good signature with unknown validity) instead of the 'G' we are aiming for (good (valid) signature). We apply this blindly on all keys known to gpg as all keys are added by the user and thus already trusted. --- cmd/fluxd/main.go | 2 +- gpg/gpg.go | 21 ++++++++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/cmd/fluxd/main.go b/cmd/fluxd/main.go index b8ff953d4..1cf63b0f5 100644 --- a/cmd/fluxd/main.go +++ b/cmd/fluxd/main.go @@ -244,7 +244,7 @@ func main() { // Import GPG keys, if we've been told where to look for them if *gitImportGPG != "" { - keyfiles, err := gpg.ImportKeys(*gitImportGPG) + keyfiles, err := gpg.ImportKeys(*gitImportGPG, *gitVerifySignatures) if err != nil { logger.Log("error", "failed to import GPG keys", "err", err.Error()) } diff --git a/gpg/gpg.go b/gpg/gpg.go index b5ccb4138..e5a5ad24d 100644 --- a/gpg/gpg.go +++ b/gpg/gpg.go @@ -16,7 +16,7 @@ import ( // directory will be imported, but not subdirectories (i.e., no // recursion). It returns the basenames of the succesfully imported // keys. -func ImportKeys(src string) ([]string, error) { +func ImportKeys(src string, trustImportedKeys bool) ([]string, error) { info, err := os.Stat(src) var files []string switch { @@ -54,6 +54,12 @@ func ImportKeys(src string) ([]string, error) { return imported, fmt.Errorf("errored importing keys: %v", failed) } + if trustImportedKeys { + if err = gpgTrustImportedKeys(); err != nil { + return imported, err + } + } + return imported, nil } @@ -65,3 +71,16 @@ func gpgImport(path string) error { } return nil } + +func gpgTrustImportedKeys() error { + // List imported keys and their fingerprints, grep the fingerprints, + // transform them into a format gpg understands, and pipe the output + // into --import-ownertrust. + arg := `gpg --list-keys --fingerprint | grep pub -A 1 | egrep -Ev "pub|--"|tr -d ' ' | awk 'BEGIN { FS = "\n" } ; { print $1":6:" }' | gpg --import-ownertrust` + cmd := exec.Command("sh", "-c", arg) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("error trusting imported keys: %s", string(out)) + } + return nil +} From f2cfdd128f9c5c18d052120193f1951e7e37353a Mon Sep 17 00:00:00 2001 From: Hidde Beydals Date: Tue, 26 Mar 2019 09:43:27 +0100 Subject: [PATCH 3/5] Document Git GPG signing and verification --- deploy/flux-deployment.yaml | 34 ++++- site/git-commit-signing.md | 34 ----- site/git-gpg.md | 247 ++++++++++++++++++++++++++++++++++++ 3 files changed, 275 insertions(+), 40 deletions(-) delete mode 100644 site/git-commit-signing.md create mode 100644 site/git-gpg.md diff --git a/deploy/flux-deployment.yaml b/deploy/flux-deployment.yaml index 7bfd7cdbb..9ad63ffa9 100644 --- a/deploy/flux-deployment.yaml +++ b/deploy/flux-deployment.yaml @@ -49,6 +49,15 @@ spec: # configMap: # name: flux-kubeconfig + # The following volume is used to import GPG keys (for signing + # and verification purposes). You will also need to provide the + # secret with the keys, and uncomment the volumeMount and args + # below. + # - name: gpg-keys + # secret: + # secretName: flux-gpg-keys + # defaultMode: 0400 + containers: - name: flux # There are no ":latest" images for flux. Find the most recent @@ -87,33 +96,46 @@ spec: # - name: KUBECONFIG # value: /etc/fluxd/kube/config + # Include this and the volume "gpg-keys" above, and the + # args below. + # - name: gpg-keys + # mountPath: /root/gpg-import + # readOnly: true + args: - # if you deployed memcached in a different namespace to flux, + # If you deployed memcached in a different namespace to flux, # or with a different service name, you can supply these # following two arguments to tell fluxd how to connect to it. # - --memcached-hostname=memcached.default.svc.cluster.local - # use the memcached ClusterIP service name by setting the + # Use the memcached ClusterIP service name by setting the # memcached-service to string empty - --memcached-service= - # this must be supplied, and be in the tmpfs (emptyDir) + # This must be supplied, and be in the tmpfs (emptyDir) # mounted above, for K8s >= 1.10 - --ssh-keygen-dir=/var/fluxd/keygen - # replace or remove the following URL + # Replace or remove the following URL. - --git-url=git@github.com:weaveworks/flux-get-started - --git-branch=master # include this if you want to restrict the manifests considered by flux # to those under the following relative paths in the git repository # - --git-path=subdir1,subdir2 - # include these next two to connect to an "upstream" service + # Include these two to enable git commit signing + # - --git-gpg-key-import=/root/gpg-import + # - --git-signing-key= + + # Include this to enable git signature verification + # - --git-verify-signatures + + # Include these next two to connect to an "upstream" service # (e.g., Weave Cloud). The token is particular to the service. # - --connect=wss://cloud.weave.works/api/flux # - --token=abc123abc123abc123abc123 - # serve /metrics endpoint at different port. + # Serve /metrics endpoint at different port; # make sure to set prometheus' annotation to scrape the port value. - --listen-metrics=:3031 diff --git a/site/git-commit-signing.md b/site/git-commit-signing.md deleted file mode 100644 index 139469fbb..000000000 --- a/site/git-commit-signing.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -title: Git commit signing -menu_order: 90 ---- - -# Summary - -Flux can be configured to sign commits that it makes to the user git -repo when, for example, it detects an updated Docker image is available -for a release with automatic deployments enabled. Enabling this feature -requires the configuration of two flags: - -1. `--git-gpg-key-import` should be set to the path Flux should look - for GPG key(s) to import, this can be a direct path to a key or the - path to a folder Flux should scan for files. -2. `--git-signing-key` should be set to the ID of the key Flux should - use to sign commits, for example: `649C056644DBB17D123D699B42532AEA4FFBFC0B` - -# Importing GPG key(s) - -Any file found in the configured `--git-gpg-key-import` path will be -imported into GPG; therefore, by volume-mounting a key into that -directory it will be made available for use by Flux. - -> **Note:** Flux *does not* recursively scan a given directory but does -understand symbolic links to files. - -# Using the `--git-signing-key` flag - -Once a key has been imported, all that needs to be done is to specify -that git commit signing should be performed by providing the -`--git-signing-key` flag and the ID of the key to use. For example: - -`--git-signing-key 649C056644DBB17D123D699B42532AEA4FFBFC0B` diff --git a/site/git-gpg.md b/site/git-gpg.md new file mode 100644 index 000000000..5e7c0eaf3 --- /dev/null +++ b/site/git-gpg.md @@ -0,0 +1,247 @@ +--- +title: Git commit signing and verification +menu_order: 90 +--- + +- [Summary](#summary) +- [Commit signing](#commit-signing) + * [Creating a GPG signing key](#creating-a-gpg-signing-key) + * [Importing a GPG signing key](#importing-a-gpg-signing-key) +- [Signature verification](#signature-verification) + * [Importing trusted GPG keys and enabling verification](#importing-trusted-gpg-keys-and-enabling-verification) + * [Enabling verification for existing repositories, disaster recovery, and deleted sync tags](#enabling-verification-for-existing-repositories-disaster-recovery-and-deleted-sync-tags) + +# Summary + +Flux can be configured to sign commits that it makes to the user git +repo when, for example, it detects an updated Docker image is available +for a release with automatic deployments enabled. To complete this +functionality it is also able to verify signatures of commits (and the +sync tag in git) to prevent Flux from applying unauthorized changes on +the cluster. + +# Commit signing + +The signing of commits (and the sync tag) requires two flags to be set: + +1. `--git-gpg-key-import` should be set to the path Flux should look + for GPG key(s) to import, this can be a direct path to a key or the + path to a folder Flux should scan for files. +2. `--git-signing-key` should be set to the ID of the key Flux should + use to sign commits, this can be the full fingerprint or the long + ID, for example: `700D397C988079BFF0DDAFED6A7436E8790F8689` (or + `6A7436E8790F8689`) + +Once enabled Flux will sign both commits and the sync tag with given +`--git-signing-key`. + +## Creating a GPG signing key + +> **Note:** This requires [gnupg](https://www.gnupg.org) to be +installed on your system. + +1. Enter the following shell command to start the key generation dialog: + + ```sh + $ gpg --full-generate-key + ``` + +2. The dialog will guide you through the process of generating a key. + Pressing the `Enter` key will assign the default value, please note + that when in doubt, in almost all cases, the default value is + recommended. + + Select what kind of key you want and press `Enter`: + + ```sh + Please select what kind of key you want: + (1) RSA and RSA (default) + (2) DSA and Elgamal + (3) DSA (sign only) + (4) RSA (sign only) + Your selection? 1 + ``` + +3. Enter the desired key size (or simply press `Enter` as the default + will be secure for almost any setup): + + ```sh + RSA keys may be between 1024 and 4096 bits long. + What keysize do you want? (2048) + ``` + +4. Specify how long the key should be valid (or simply press `Enter`): + + ```sh + Please specify how long the key should be valid. + 0 = key does not expire + = key expires in n days + w = key expires in n weeks + m = key expires in n months + y = key expires in n years + Key is valid for? (0) + ``` + +5. Verify your selection of choices and accept (`y` and `Enter`) + +6. Enter your user ID information, it is recommended to set the email + address to the same address as the daemon uses for Git operations. + +7. **Do not enter a passphrase**, as Flux will be unable to sign with a + passphrase protected private key, instead, keep it in a secure place. + +8. You can validate the public and private keypair were created with + success by running: + + ```sh + $ gpg --list-secret-keys --keyid-format long + sec rsa2048/6A7436E8790F8689 2019-03-28 [SC] + 700D397C988079BFF0DDAFED6A7436E8790F8689 + uid [ultimate] Weaveworks Flux + ssb rsa2048/ECA4FF5BD988B8E9 2019-03-28 [E] + ``` + +## Importing a GPG signing key + +Any file found in the configured `--git-gpg-key-import` path will be +imported into GPG; therefore, by volume-mounting a key into that +directory it will be made available for use by Flux. + +1. Retrieve the key ID (second row of the `sec` column): + + ```sh + $ gpg --list-secret-keys --keyid-format long + sec rsa2048/6A7436E8790F8689 2019-03-28 [SC] + 700D397C988079BFF0DDAFED6A7436E8790F8689 + uid [ultimate] Weaveworks Flux + ssb rsa2048/ECA4FF5BD988B8E9 2019-03-28 [E] + ``` + +2. Export the public and private keypair from your local GPG keyring + to a Kubernetes secret with `--export-secret-keys `: + + ```sh + $ gpg --export-secret-keys 700D397C988079BFF0DDAFED6A7436E8790F8689 | + kubectl create secret generic flux-gpg-keys --from-file=flux.asc=/dev/stdin --dry-run -o yaml + apiVersion: v1 + data: + flux.asc: + kind: Secret + metadata: + creationTimestamp: null + name: flux-gpg-keys + +3. Adapt your Flux deployment to mount the secret and enable the + signing of commits: + + ```yaml + spec: + template: + spec: + volumes: + - name: gpg-keys + secret: + secretName: flux-gpg-keys + defaultMode: 0400 + containers: + - name: flux + volumeMounts: + - name: gpg-keys + mountPath: /root/gpg-import + readOnly: true + args: + - --git-gpg-key-import=/root/gpg-import + - --git-signing-key=700D397C988079BFF0DDAFED6A7436E8790F8689 # key id + ``` + + or set the `gpgKeys.secretName` in your Helm `values.yaml` to + `gpg-keys`, and `signingKey` to your ``. + +4. To validate your setup is working, run `git log --show-signature` or + `git verify-tag ` to assure Flux signs its git + actions. + + ```sh + $ git verify-tag + gpg: Signature made vr 29 mrt 2019 15:28:34 CET + gpg: using RSA key 700D397C988079BFF0DDAFED6A7436E8790F8689 + gpg: Good signature from "Weaveworks Flux " [ultimate] + ``` + +> **Note:** Flux *does not* recursively scan a given directory but does +understand symbolic links to files. + +> **Note:** Flux will automatically add any imported key to the GnuPG + trustdb. This is required as git will otherwise not trust signatures + made with the imported keys. + +# Signature verification + +The verification of commit signatures is enabled by importing all +trusted public keys (`--git-gpg-key-import=`), and by setting the +`--gpg-verify-signatures` flag. Once enabled Flux will verify all +commit signatures, and the signature from the sync tag it is comparing +revisions with. + +In case a signature can not be verified, Flux will sync state up to the +last valid revision it can find _before_ the unverified commit was +made, and lock on this revision. + +## Importing trusted GPG keys and enabling verification + +1. Collect the public keys from all trusted git authors. + +2. Add the collected keys to your secret with GPG keys. + + ```yaml + apiVersion: v1 + kind: Secret + metadata: + name: flux-gpg-keys + data: + # ... + author.asc: + ``` + +3. Adapt your Flux deployment to enable the verification of commits: + + ```yaml + spec: + template: + spec: + volumes: + - name: gpg-keys + secret: + secretName: flux-gpg-keys + defaultMode: 0400 + containers: + - name: flux + volumeMounts: + - name: gpg-keys + mountPath: /root/gpg-import + readOnly: true + args: + - --git-verify-signatures + ``` + +> **Note:** Flux *does not* recursively scan a given directory but does +understand symbolic links to files. + +## Enabling verification for existing repositories, disaster recovery, and deleted sync tags + +In case you have existing commits in your repository without a +signature you may want to: + +a. First enable signing by setting the `--git-gpg-key-import` and + `--git-signing-key`, after Flux has synchronized the first commit + with a signature, enable verification. + +b. Sign the sync tag by yourself, with a key that is imported, to point + towards the first commit with a signature (or the current `HEAD`). + Flux will then start synchronizing the changes between the sync tag + revision and `HEAD`. + + ```sh + $ git tag --force --local-user= -a -m "Sync pointer" + $ git push --force origin + ``` From d17767f7bdcb1bca40924f7854f1e4c7af985b29 Mon Sep 17 00:00:00 2001 From: Hidde Beydals Date: Wed, 10 Apr 2019 14:37:47 +0200 Subject: [PATCH 4/5] Support importing GPG keys from multiple paths This makes it possible to, for example, seperate the GPG private key Flux signs commits with from the public keys Flux verifies commits with. --- cmd/fluxd/main.go | 11 ++++---- site/git-gpg.md | 72 +++++++++++++++++++++++++---------------------- 2 files changed, 45 insertions(+), 38 deletions(-) diff --git a/cmd/fluxd/main.go b/cmd/fluxd/main.go index 1cf63b0f5..8d7a4e16f 100644 --- a/cmd/fluxd/main.go +++ b/cmd/fluxd/main.go @@ -120,7 +120,7 @@ func main() { gitTimeout = fs.Duration("git-timeout", 20*time.Second, "duration after which git operations time out") // GPG commit signing - gitImportGPG = fs.String("git-gpg-key-import", "", "keys at the path given (either a file or a directory) will be imported for use in signing commits") + gitImportGPG = fs.StringSlice("git-gpg-key-import", []string{}, "keys at the paths given will be imported for use of signing and verifying commits") gitSigningKey = fs.String("git-signing-key", "", "if set, commits Flux makes will be signed with this GPG key") gitVerifySignatures = fs.Bool("git-verify-signatures", false, "if set, the signature of commits will be verified before Flux applies them") @@ -153,6 +153,7 @@ func main() { k8sSecretDataKey = fs.String("k8s-secret-data-key", "identity", "data key holding the private SSH key within the k8s secret") k8sNamespaceWhitelist = fs.StringSlice("k8s-namespace-whitelist", []string{}, "experimental, optional: restrict the view of the cluster to the namespaces listed. All namespaces are included if this is not set") k8sAllowNamespace = fs.StringSlice("k8s-allow-namespace", []string{}, "experimental: restrict all operations to the provided namespaces") + // SSH key generation sshKeyBits = optionalVar(fs, &ssh.KeyBitsValue{}, "ssh-keygen-bits", "-b argument to ssh-keygen (default unspecified)") sshKeyType = optionalVar(fs, &ssh.KeyTypeValue{}, "ssh-keygen-type", "-t argument to ssh-keygen (default unspecified)") @@ -243,13 +244,13 @@ func main() { } // Import GPG keys, if we've been told where to look for them - if *gitImportGPG != "" { - keyfiles, err := gpg.ImportKeys(*gitImportGPG, *gitVerifySignatures) + for _, p := range *gitImportGPG { + keyfiles, err := gpg.ImportKeys(p, *gitVerifySignatures) if err != nil { - logger.Log("error", "failed to import GPG keys", "err", err.Error()) + logger.Log("error", fmt.Sprintf("failed to import GPG key(s) from %s", p), "err", err.Error()) } if keyfiles != nil { - logger.Log("info", "imported GPG keys", "files", fmt.Sprintf("%v", keyfiles)) + logger.Log("info", fmt.Sprintf("imported GPG key(s) from %s", p), "files", fmt.Sprintf("%v", keyfiles)) } } diff --git a/site/git-gpg.md b/site/git-gpg.md index 5e7c0eaf3..c89d987b2 100644 --- a/site/git-gpg.md +++ b/site/git-gpg.md @@ -24,9 +24,9 @@ the cluster. The signing of commits (and the sync tag) requires two flags to be set: -1. `--git-gpg-key-import` should be set to the path Flux should look - for GPG key(s) to import, this can be a direct path to a key or the - path to a folder Flux should scan for files. +1. `--git-gpg-key-import` should be set to the path(s) Flux should look + for GPG key(s) to import, this can be direct paths to keys and/or + the paths to folders Flux should scan for files. 2. `--git-signing-key` should be set to the ID of the key Flux should use to sign commits, this can be the full fingerprint or the long ID, for example: `700D397C988079BFF0DDAFED6A7436E8790F8689` (or @@ -103,7 +103,7 @@ installed on your system. ## Importing a GPG signing key -Any file found in the configured `--git-gpg-key-import` path will be +Any file found in the configured `--git-gpg-key-import` path(s) will be imported into GPG; therefore, by volume-mounting a key into that directory it will be made available for use by Flux. @@ -122,14 +122,15 @@ directory it will be made available for use by Flux. ```sh $ gpg --export-secret-keys 700D397C988079BFF0DDAFED6A7436E8790F8689 | - kubectl create secret generic flux-gpg-keys --from-file=flux.asc=/dev/stdin --dry-run -o yaml + kubectl create secret generic flux-gpg-signing-key --from-file=flux.asc=/dev/stdin --dry-run -o yaml apiVersion: v1 data: flux.asc: kind: Secret metadata: creationTimestamp: null - name: flux-gpg-keys + name: flux-gpg-signing-key + ``` 3. Adapt your Flux deployment to mount the secret and enable the signing of commits: @@ -139,18 +140,18 @@ directory it will be made available for use by Flux. template: spec: volumes: - - name: gpg-keys - secret: - secretName: flux-gpg-keys - defaultMode: 0400 + - name: gpg-signing-key + secret: + secretName: flux-gpg-signing-key + defaultMode: 0400 containers: - name: flux volumeMounts: - - name: gpg-keys - mountPath: /root/gpg-import + - name: gpg-signing-key + mountPath: /root/gpg-signing-key/ readOnly: true args: - - --git-gpg-key-import=/root/gpg-import + - --git-gpg-key-import=/root/gpg-signing-key - --git-signing-key=700D397C988079BFF0DDAFED6A7436E8790F8689 # key id ``` @@ -178,10 +179,10 @@ understand symbolic links to files. # Signature verification The verification of commit signatures is enabled by importing all -trusted public keys (`--git-gpg-key-import=`), and by setting the -`--gpg-verify-signatures` flag. Once enabled Flux will verify all -commit signatures, and the signature from the sync tag it is comparing -revisions with. +trusted public keys (`--git-gpg-key-import=,`), and by +setting the `--gpg-verify-signatures` flag. Once enabled Flux will +verify all commit signatures, and the signature from the sync tag it is +comparing revisions with. In case a signature can not be verified, Flux will sync state up to the last valid revision it can find _before_ the unverified commit was @@ -191,36 +192,41 @@ made, and lock on this revision. 1. Collect the public keys from all trusted git authors. -2. Add the collected keys to your secret with GPG keys. +2. Create a `ConfigMap` with all trusted public keys: - ```yaml - apiVersion: v1 - kind: Secret - metadata: - name: flux-gpg-keys - data: - # ... - author.asc: + ```sh + $ kubectl create configmap generic flux-gpg-public-keys \ + --from-file=author.asc --from-file=author2.asc --dry-run -o yaml + apiVersion: v1 + data: + author.asc: + author2.asc: + kind: ConfigMap + metadata: + creationTimestamp: null + name: flux-gpg-public-keys ``` -3. Adapt your Flux deployment to enable the verification of commits: +3. Mount the config map in your Flux deployment, add the mount path to + `--git-gpg-key-import`, and enable the verification of commits: ```yaml spec: template: spec: volumes: - - name: gpg-keys - secret: - secretName: flux-gpg-keys - defaultMode: 0400 + - name: gpg-public-keys + configMap: + name: flux-gpg-public-keys + defaultMode: 0400 containers: - name: flux volumeMounts: - - name: gpg-keys - mountPath: /root/gpg-import + - name: gpg-public-keys + mountPath: /root/gpg-public-keys readOnly: true args: + - --git-gpg-key-import=/root/gpg-public-keys - --git-verify-signatures ``` From 39664664b20f6bc9809e49ea134195ca03123025 Mon Sep 17 00:00:00 2001 From: Hidde Beydals Date: Thu, 4 Apr 2019 15:06:09 +0200 Subject: [PATCH 5/5] Ensure Git >=2.12 in both images and CI --- .circleci/config.yml | 7 +++++++ docker/Dockerfile.flux | 2 +- docker/Dockerfile.helm-operator | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index b675a0d8e..d47399a96 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -9,6 +9,13 @@ jobs: - checkout - setup_remote_docker + - run: + # Ensure latest version of git + command: | + echo "deb http://deb.debian.org/debian stretch-backports main" | sudo tee -a /etc/apt/sources.list.d/stretch-backports.list + sudo apt-get update + sudo apt-get install -t stretch-backports -y --only-upgrade git + git version - run: curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh - run: dep ensure -vendor-only - run: make check-generated diff --git a/docker/Dockerfile.flux b/docker/Dockerfile.flux index 6c4d5bb4e..4bf186daa 100644 --- a/docker/Dockerfile.flux +++ b/docker/Dockerfile.flux @@ -2,7 +2,7 @@ FROM alpine:3.9 WORKDIR /home/flux -RUN apk add --no-cache openssh ca-certificates tini 'git>=2.3.0' gnupg +RUN apk add --no-cache openssh ca-certificates tini 'git>=2.12.0' gnupg # Add git hosts to known hosts file so we can use # StrickHostKeyChecking with git+ssh diff --git a/docker/Dockerfile.helm-operator b/docker/Dockerfile.helm-operator index fca75fc0a..acf6165a1 100644 --- a/docker/Dockerfile.helm-operator +++ b/docker/Dockerfile.helm-operator @@ -2,7 +2,7 @@ FROM alpine:3.9 WORKDIR /home/flux -RUN apk add --no-cache openssh ca-certificates tini 'git>=2.3.0' +RUN apk add --no-cache openssh ca-certificates tini 'git>=2.12.0' # Add git hosts to known hosts file so we can use # StrickHostKeyChecking with git+ssh