From 984c94c17924cf8ce515ea018803c161f8b75f28 Mon Sep 17 00:00:00 2001 From: George MacRorie Date: Mon, 24 Jul 2023 14:54:23 +0100 Subject: [PATCH] feat: initial implementation of git.FilesystemStore --- go.mod | 5 +- go.sum | 7 + pkg/api/server.go | 39 +++-- pkg/controller/controller.go | 11 +- pkg/controllers/template/template.go | 9 +- pkg/fs/git/filesystem.go | 251 +++++++++++++++++++++++++++ pkg/fs/git/store.go | 48 +++++ pkg/fs/mem/store.go | 9 +- 8 files changed, 353 insertions(+), 26 deletions(-) create mode 100644 pkg/fs/git/filesystem.go create mode 100644 pkg/fs/git/store.go diff --git a/go.mod b/go.mod index a173126..bd2e409 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,9 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/gofrs/uuid v4.4.0+incompatible // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/hashicorp/golang-lru/v2 v2.0.4 // indirect github.com/imdario/mergo v0.3.15 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect @@ -30,7 +32,8 @@ require ( github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonschema v1.2.0 // indirect golang.org/x/crypto v0.10.0 // indirect - golang.org/x/mod v0.10.0 // indirect + golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 // indirect + golang.org/x/mod v0.11.0 // indirect golang.org/x/net v0.11.0 // indirect golang.org/x/sync v0.3.0 // indirect golang.org/x/sys v0.9.0 // indirect diff --git a/go.sum b/go.sum index 8336097..59c4a13 100644 --- a/go.sum +++ b/go.sum @@ -28,9 +28,13 @@ github.com/go-git/go-billy/v5 v5.4.1/go.mod h1:vjbugF6Fz7JIflbVpl1hJsGjSHNltrSw4 github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20230305113008-0c11038e723f h1:Pz0DHeFij3XFhoBRGUDPzSJ+w2UcK5/0JvF8DRI58r8= github.com/go-git/go-git/v5 v5.7.0 h1:t9AudWVLmqzlo+4bqdf7GY+46SUuRsx59SboFxkq2aE= github.com/go-git/go-git/v5 v5.7.0/go.mod h1:coJHKEOk5kUClpsNlXrUvPrDxY3w3gjHvhcZd8Fodw8= +github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= +github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/hashicorp/golang-lru/v2 v2.0.4 h1:7GHuZcgid37q8o5i3QI9KMT4nCWQQ3Kx3Ov6bb9MfK0= +github.com/hashicorp/golang-lru/v2 v2.0.4/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/imdario/mergo v0.3.15 h1:M8XP7IuFNsqUx6VPK2P9OSmsYsI/YFaGil0uD21V3dM= github.com/imdario/mergo v0.3.15/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= @@ -79,10 +83,13 @@ golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0 golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.10.0 h1:LKqV2xt9+kDzSTfOhx4FrkEBcMrAgHSYgzywV9zcGmM= golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I= +golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 h1:MGwJjxBy0HJshjDNfLsYO8xppfqWlA5ZT9OhtUUhTNw= +golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk= golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.11.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= diff --git a/pkg/api/server.go b/pkg/api/server.go index 6fadc8b..3816044 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "io/fs" "net/http" "path" "sort" @@ -14,9 +15,13 @@ import ( "go.flipt.io/cup/pkg/controller" ) -// FSFunc is a function passed to a FilesystemStore implementation to be invoked -// over a provided FSConfig in either a View or Update transaction context. -type FSFunc func(controller.FSConfig) error +// ViewFunc is a function provided to FilesystemStore.View. +// It is provided with a read-only view of the target filesystem. +type ViewFunc func(fs.FS) error + +// UpdateFunc is a function passed to a FilesystemStore implementation to be invoked +// over a provided FSConfig in a call to Update. +type UpdateFunc func(controller.FSConfig) error // Result is the result of performing an update on a target FilesystemStore. type Result struct{} @@ -27,11 +32,11 @@ type Result struct{} type FilesystemStore interface { // View invokes the provided function with an FSConfig which should enforce // a read-only view for the requested source and revision - View(_ context.Context, source, revision string, fn FSFunc) error + View(_ context.Context, source, revision string, fn ViewFunc) error // Update invokes the provided function with an FSConfig which can be written to // Any writes performed to the target during the execution of fn will be added, // comitted, pushed and proposed for review on a target SCM - Update(_ context.Context, source, revision string, fn FSFunc) (*Result, error) + Update(_ context.Context, source, revision, message string, fn UpdateFunc) (*Result, error) } // Controller is the core controller interface for handling interactions with a @@ -90,7 +95,7 @@ func (s *Server) addDefinition(source string, gvk string, def *core.ResourceDefi } // RegisterController adds a new controller and definition for a particular source to the server. -// This potentially will happen dynamically in the future, so it is guarded with a write lock. +// This may happen dynamically in the future, so it is guarded with a write lock. func (s *Server) RegisterController(source string, cntl Controller) { s.mu.Lock() defer s.mu.Unlock() @@ -108,15 +113,15 @@ func (s *Server) RegisterController(source string, cntl Controller) { // list kind s.mux.Get(prefix, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if err := s.fs.View(r.Context(), source, s.rev, func(f controller.FSConfig) error { + if err := s.fs.View(r.Context(), source, s.rev, func(f fs.FS) error { resources, err := cntl.List(r.Context(), &controller.ListRequest{ Request: controller.Request{ - FSConfig: f, Group: def.Spec.Group, Version: version, Kind: def.Names.Kind, Namespace: chi.URLParamFromCtx(r.Context(), "ns"), }, + FS: f, }) if err != nil { return err @@ -138,15 +143,15 @@ func (s *Server) RegisterController(source string, cntl Controller) { // get kind s.mux.Get(named, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if err := s.fs.View(r.Context(), source, s.rev, func(f controller.FSConfig) error { + if err := s.fs.View(r.Context(), source, s.rev, func(f fs.FS) error { resource, err := cntl.Get(r.Context(), &controller.GetRequest{ Request: controller.Request{ - FSConfig: f, Group: def.Spec.Group, Version: version, Kind: def.Names.Kind, Namespace: chi.URLParamFromCtx(r.Context(), "ns"), }, + FS: f, Name: chi.URLParamFromCtx(r.Context(), "name"), }) if err != nil { @@ -162,7 +167,9 @@ func (s *Server) RegisterController(source string, cntl Controller) { // put kind s.mux.Put(named, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - result, err := s.fs.Update(r.Context(), source, s.rev, func(f controller.FSConfig) error { + // TODO(georgemac): derive a suitable message + var message string + result, err := s.fs.Update(r.Context(), source, s.rev, message, func(f controller.FSConfig) error { var resource core.Resource if err := json.NewDecoder(r.Body).Decode(&resource); err != nil { return err @@ -170,12 +177,12 @@ func (s *Server) RegisterController(source string, cntl Controller) { return cntl.Put(r.Context(), &controller.PutRequest{ Request: controller.Request{ - FSConfig: f, Group: def.Spec.Group, Version: version, Kind: def.Names.Kind, Namespace: chi.URLParamFromCtx(r.Context(), "ns"), }, + FSConfig: f, Name: chi.URLParamFromCtx(r.Context(), "name"), Resource: &resource, }) @@ -193,16 +200,18 @@ func (s *Server) RegisterController(source string, cntl Controller) { // delete kind s.mux.Delete(named, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - result, err := s.fs.Update(r.Context(), source, s.rev, func(f controller.FSConfig) error { + // TODO(georgemac): derive a suitable message + var message string + result, err := s.fs.Update(r.Context(), source, s.rev, message, func(f controller.FSConfig) error { return cntl.Delete(r.Context(), &controller.DeleteRequest{ Request: controller.Request{ - FSConfig: f, Group: def.Spec.Group, Version: version, Kind: def.Names.Kind, Namespace: chi.URLParamFromCtx(r.Context(), "ns"), }, - Name: chi.URLParamFromCtx(r.Context(), "name"), + FSConfig: f, + Name: chi.URLParamFromCtx(r.Context(), "name"), }) }) if err != nil { diff --git a/pkg/controller/controller.go b/pkg/controller/controller.go index bbd936e..5605bc9 100644 --- a/pkg/controller/controller.go +++ b/pkg/controller/controller.go @@ -1,11 +1,14 @@ package controller -import "go.flipt.io/cup/pkg/api/core" +import ( + "io/fs" + + "go.flipt.io/cup/pkg/api/core" +) type Controller struct{} type Request struct { - FSConfig Group string Version string Kind string @@ -14,21 +17,25 @@ type Request struct { type GetRequest struct { Request + FS fs.FS Name string } type ListRequest struct { Request + FS fs.FS Labels [][2]string } type PutRequest struct { Request + FSConfig Name string Resource *core.Resource } type DeleteRequest struct { Request + FSConfig Name string } diff --git a/pkg/controllers/template/template.go b/pkg/controllers/template/template.go index fa02070..dfb9d33 100644 --- a/pkg/controllers/template/template.go +++ b/pkg/controllers/template/template.go @@ -5,11 +5,11 @@ import ( "context" "fmt" "io" + "io/fs" "os" "strings" "text/template" - "github.com/go-git/go-billy/v5/util" "go.flipt.io/cup/pkg/api/core" "go.flipt.io/cup/pkg/containers" "go.flipt.io/cup/pkg/controller" @@ -87,7 +87,7 @@ func (c *Controller) Get(_ context.Context, req *controller.GetRequest) (_ *core return nil, err } - fi, err := req.FSConfig.ToFS().Open(buf.String()) + fi, err := req.FS.Open(buf.String()) if err != nil { return nil, err } @@ -110,14 +110,13 @@ func (c *Controller) List(_ context.Context, req *controller.ListRequest) (resou return nil, err } - ffs := req.FSConfig.ToFS() - matches, err := util.Glob(ffs, buf.String()) + matches, err := fs.Glob(req.FS, buf.String()) if err != nil { return nil, err } for _, match := range matches { - fi, err := ffs.Open(match) + fi, err := req.FS.Open(match) if err != nil { return nil, err } diff --git a/pkg/fs/git/filesystem.go b/pkg/fs/git/filesystem.go new file mode 100644 index 0000000..36b0405 --- /dev/null +++ b/pkg/fs/git/filesystem.go @@ -0,0 +1,251 @@ +package git + +import ( + "context" + "errors" + "fmt" + "os" + "time" + + "github.com/go-git/go-billy/v5/osfs" + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/config" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/go-git/go-git/v5/plumbing/transport" + "github.com/go-git/go-git/v5/storage/memory" + "github.com/gofrs/uuid" + "go.flipt.io/cup/pkg/api" + "go.flipt.io/cup/pkg/containers" + "go.flipt.io/cup/pkg/controller" + "go.flipt.io/cup/pkg/gitfs" + "golang.org/x/exp/slog" +) + +type Proposal struct { + Head string + Base string + Title string + Body string +} + +type ProposalResponse struct{} + +type SCM interface { + Propose(context.Context, Proposal) (ProposalResponse, error) +} + +// FilesystemStore is an implementation of api.FilesystemStore +// This implementation is backed by a Git repository and it tracks an upstream reference. +// When subscribing to this source, the upstream reference is tracked +// by polling the upstream on a configurable interval. +type Filesystem struct { + logger *slog.Logger + repo *git.Repository + storage *memory.Storage + + url string + scm SCM + interval time.Duration + auth transport.AuthMethod +} + +// WithPollInterval configures the interval in which origin is polled to +// discover any updates to the target reference. +func WithPollInterval(tick time.Duration) containers.Option[Filesystem] { + return func(s *Filesystem) { + s.interval = tick + } +} + +// WithAuth returns an option which configures the auth method used +// by the provided source. +func WithAuth(auth transport.AuthMethod) containers.Option[Filesystem] { + return func(s *Filesystem) { + s.auth = auth + } +} + +// NewFilesystem constructs and configures a Git backend Filesystem. +// The implementation uses the connection and credential details provided to support +// view and update requests for use in the api server. +func NewFilesystem(ctx context.Context, scm SCM, url string, opts ...containers.Option[Filesystem]) (_ *Filesystem, err error) { + fs := &Filesystem{ + logger: slog.With(slog.String("repository", url)), + url: url, + scm: scm, + interval: 30 * time.Second, + } + containers.ApplyAll(fs, opts...) + + fs.storage = memory.NewStorage() + fs.repo, err = git.Clone(fs.storage, nil, &git.CloneOptions{ + Auth: fs.auth, + URL: fs.url, + }) + if err != nil { + return nil, err + } + + go fs.pollRefs(ctx) + + return fs, nil +} + +// View builds a new fs.FS based on the configure Git remote and reference. +// It call the provided function with the derived fs.FS. +func (s *Filesystem) View(ctx context.Context, rev string, fn api.ViewFunc) error { + hash, err := s.resolve(rev) + if err != nil { + return err + } + + fs, err := gitfs.NewFromRepoHash(s.repo, hash) + if err != nil { + return err + } + + return fn(fs) +} + +// Update builds a worktree in a temporary directory for the provided revision over the configured Git repository. +// The provided function is called with the checked out worktree. +// Any changes made during the function call to the underlying worktree are added commit and pushed to the +// target Git repository. +// Once pushed a proposal is made on the configured SCM. +func (s *Filesystem) Update(ctx context.Context, rev, message string, fn api.UpdateFunc) (*api.Result, error) { + hash, err := s.resolve(rev) + if err != nil { + return nil, err + } + + // shallow copy the store without the existing index + store := &memory.Storage{ + ReferenceStorage: s.storage.ReferenceStorage, + ConfigStorage: s.storage.ConfigStorage, + ShallowStorage: s.storage.ShallowStorage, + ObjectStorage: s.storage.ObjectStorage, + ModuleStorage: s.storage.ModuleStorage, + } + + dir, err := os.MkdirTemp("", "cup-proposal-*") + if err != nil { + return nil, err + } + + defer func() { + _ = os.RemoveAll(dir) + }() + + // open repository on store with in-memory workspace + repo, err := git.Open(store, osfs.New(dir)) + if err != nil { + return nil, fmt.Errorf("open repo: %w", err) + } + + work, err := repo.Worktree() + if err != nil { + return nil, fmt.Errorf("open worktree: %w", err) + } + + id := uuid.Must(uuid.NewV4()).String() + result := &api.Result{} + + // create proposal branch (cup/proposal/$id) + branch := fmt.Sprintf("cup/proposal/%s", id) + if err := repo.CreateBranch(&config.Branch{ + Name: branch, + Remote: "origin", + }); err != nil { + return nil, fmt.Errorf("create branch: %w", err) + } + + if err := work.Checkout(&git.CheckoutOptions{ + Branch: plumbing.NewBranchReferenceName(branch), + Create: true, + Hash: hash, + }); err != nil { + return nil, fmt.Errorf("checkout branch: %w", err) + } + + if err := fn(controller.NewDirFSConfig(dir)); err != nil { + return nil, fmt.Errorf("execute proposal: %w", err) + } + + if err := work.AddWithOptions(&git.AddOptions{All: true}); err != nil { + return nil, fmt.Errorf("adding changes: %w", err) + } + + _, err = work.Commit(message, &git.CommitOptions{ + Author: &object.Signature{Email: "cup@flipt.io", Name: "cup"}, + Committer: &object.Signature{Email: "cup@flipt.io", Name: "cup"}, + }) + if err != nil { + return nil, fmt.Errorf("committing changes: %w", err) + } + + s.logger.Debug("Pushing Changes", slog.String("branch", branch)) + + b, err := repo.Branch(branch) + if err != nil { + return nil, err + } + s.logger.Debug("Branch", slog.String("name", b.Name), slog.String("remote", b.Remote)) + + // push to proposed branch + if err := repo.PushContext(ctx, &git.PushOptions{ + Auth: s.auth, + RemoteName: "origin", + RefSpecs: []config.RefSpec{ + config.RefSpec(fmt.Sprintf("%s:refs/heads/%s", branch, branch)), + config.RefSpec(fmt.Sprintf("refs/heads/%s:refs/heads/%s", branch, branch)), + }, + }); err != nil { + return nil, fmt.Errorf("pushing changes: %w", err) + } + + if _, err := s.scm.Propose(ctx, Proposal{ + Head: branch, + Base: "main", + Title: message, + // TODO(georgmac): define a sensible body + }); err != nil { + return nil, err + } + + return result, nil +} + +func (s *Filesystem) resolve(r string) (plumbing.Hash, error) { + if plumbing.IsHash(r) { + return plumbing.NewHash(r), nil + } + + ref, err := s.repo.Reference(plumbing.NewBranchReferenceName(r), true) + if err != nil { + return plumbing.ZeroHash, err + } + + return ref.Hash(), nil +} + +func (s *Filesystem) pollRefs(ctx context.Context) { + ticker := time.NewTicker(s.interval) + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := s.repo.FetchContext(ctx, &git.FetchOptions{ + Auth: s.auth, + }); err != nil { + if errors.Is(err, git.NoErrAlreadyUpToDate) { + slog.Debug("References are all up to date") + continue + } + + slog.Error("Fetching references", "error", err) + } + } + } +} diff --git a/pkg/fs/git/store.go b/pkg/fs/git/store.go new file mode 100644 index 0000000..336c1d5 --- /dev/null +++ b/pkg/fs/git/store.go @@ -0,0 +1,48 @@ +package git + +import ( + "context" + "fmt" + + "go.flipt.io/cup/pkg/api" + "go.flipt.io/cup/pkg/containers" +) + +var _ api.FilesystemStore = (*FilesystemStore)(nil) + +type FilesystemStore struct { + sources containers.MapStore[string, *Filesystem] +} + +func NewFilesystemStore() *FilesystemStore { + return &FilesystemStore{ + sources: containers.MapStore[string, *Filesystem]{}, + } +} + +func (f *FilesystemStore) AddSource(source string, fs *Filesystem) { + f.sources[source] = fs +} + +// View invokes the provided function with an FSConfig which should enforce +// a read-only view for the requested source and revision +func (f *FilesystemStore) View(ctx context.Context, source string, revision string, fn api.ViewFunc) error { + fs, err := f.sources.Get(source) + if err != nil { + return fmt.Errorf("view: %w", err) + } + + return fs.View(ctx, revision, fn) +} + +// Update invokes the provided function with an FSConfig which can be written to +// Any writes performed to the target during the execution of fn will be added, +// comitted, pushed and proposed for review on a target SCM +func (f *FilesystemStore) Update(ctx context.Context, source string, revision string, message string, fn api.UpdateFunc) (*api.Result, error) { + fs, err := f.sources.Get(source) + if err != nil { + return nil, fmt.Errorf("update: %w", err) + } + + return fs.Update(ctx, revision, message, fn) +} diff --git a/pkg/fs/mem/store.go b/pkg/fs/mem/store.go index d646f30..0b8c8f0 100644 --- a/pkg/fs/mem/store.go +++ b/pkg/fs/mem/store.go @@ -6,9 +6,12 @@ import ( "github.com/go-git/go-billy/v5" "go.flipt.io/cup/pkg/api" + "go.flipt.io/cup/pkg/billyfs" "go.flipt.io/cup/pkg/controller" ) +var _ api.FilesystemStore = (*FilesystemStore)(nil) + // FilesystemStore is primarily used for testing. // It approximates a real implementation using a set of fs.FS implementations. // The implementations are indexed by source and revision internally. @@ -38,19 +41,19 @@ func (f *FilesystemStore) AddFS(source, revision string, ffs billy.Filesystem) { // View invokes the provided function with an FSConfig which should enforce // a read-only view for the requested source and revision -func (f *FilesystemStore) View(_ context.Context, source string, revision string, fn api.FSFunc) error { +func (f *FilesystemStore) View(_ context.Context, source string, revision string, fn api.ViewFunc) error { fs, err := f.fs(source, revision) if err != nil { return fmt.Errorf("view: %w", err) } - return fn(controller.NewFSConfig(fs)) + return fn(billyfs.New(fs)) } // Update invokes the provided function with an FSConfig which can be written to // Any writes performed to the target during the execution of fn will be added, // comitted, pushed and proposed for review on a target SCM -func (f *FilesystemStore) Update(_ context.Context, source string, revision string, fn api.FSFunc) (*api.Result, error) { +func (f *FilesystemStore) Update(_ context.Context, source, revision, _ string, fn api.UpdateFunc) (*api.Result, error) { fs, err := f.fs(source, revision) if err != nil { return nil, fmt.Errorf("update: %w", err)