diff --git a/Dockerfile b/Dockerfile index 01a7273db..43b7acb2c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,6 +40,10 @@ ARG VERSION ARG GIT_COMMIT ARG GIT_TREE_STATE +RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \ + -o bin/credential-helper \ + ./cmd/credential-helper + RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \ -ldflags "-w -X ${VERSION_PACKAGE}.version=${VERSION} -X ${VERSION_PACKAGE}.buildDate=$(date -u +'%Y-%m-%dT%H:%M:%SZ') -X ${VERSION_PACKAGE}.gitCommit=${GIT_COMMIT} -X ${VERSION_PACKAGE}.gitTreeState=${GIT_TREE_STATE}" \ -o bin/kargo \ @@ -92,6 +96,7 @@ FROM base AS back-end-dev USER root +COPY bin/credential-helper /usr/local/bin/credential-helper COPY bin/controlplane/kargo /usr/local/bin/kargo RUN adduser -D -H -u 1000 kargo diff --git a/Tiltfile b/Tiltfile index 8309fc988..de150b177 100644 --- a/Tiltfile +++ b/Tiltfile @@ -8,7 +8,7 @@ local_resource( 'CGO_ENABLED=0 GOOS=linux GOARCH=$(go env GOARCH) go build -o bin/controlplane/kargo ./cmd/controlplane', deps=[ 'api/', - 'cmd/', + 'cmd/controlplane/', 'internal/', 'pkg/', 'go.mod', @@ -17,10 +17,20 @@ local_resource( labels = ['native-processes'], trigger_mode = TRIGGER_MODE_AUTO ) +local_resource( + 'credential-helper-compile', + 'CGO_ENABLED=0 GOOS=linux GOARCH=$(go env GOARCH) go build -o bin/credential-helper ./cmd/credential-helper', + deps=['cmd/credential-helper/'], + labels = ['native-processes'], + trigger_mode = TRIGGER_MODE_AUTO +) docker_build( 'ghcr.io/akuity/kargo', '.', - only = ['bin/controlplane/kargo'], + only = [ + 'bin/controlplane/kargo', + 'bin/credential-helper' + ], target = 'back-end-dev', # Just the back end, built natively, copied to the image ) @@ -99,7 +109,7 @@ k8s_resource( 'kargo-controller-rollouts:clusterrole', 'kargo-controller-rollouts:clusterrolebinding' ], - resource_deps=['back-end-compile'] + resource_deps=['back-end-compile', 'credential-helper-compile', ] ) k8s_resource( diff --git a/cmd/credential-helper/main.go b/cmd/credential-helper/main.go new file mode 100644 index 000000000..224ab3697 --- /dev/null +++ b/cmd/credential-helper/main.go @@ -0,0 +1,15 @@ +package main + +import ( + "fmt" + "os" +) + +func main() { + password := os.Getenv("GIT_PASSWORD") + if password == "" { + fmt.Fprintln(os.Stderr, "GIT_PASSWORD must be set") + os.Exit(1) + } + fmt.Println(password) +} diff --git a/go.mod b/go.mod index 3138636e7..50b72de11 100644 --- a/go.mod +++ b/go.mod @@ -31,6 +31,7 @@ require ( github.com/patrickmn/go-cache v2.1.0+incompatible github.com/rs/cors v1.11.1 github.com/sirupsen/logrus v1.9.3 + github.com/sosedoff/gitkit v0.4.0 github.com/spf13/cobra v1.8.1 github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.9.0 @@ -71,7 +72,9 @@ require ( github.com/blang/semver/v4 v4.0.0 // indirect github.com/containerd/errdefs v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect + github.com/evanphx/json-patch v5.9.0+incompatible // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect + github.com/gofrs/uuid v4.0.0+incompatible // indirect github.com/google/go-github/v62 v62.0.0 // indirect github.com/google/s2a-go v0.1.8 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect @@ -102,7 +105,6 @@ require ( github.com/docker/go-metrics v0.0.1 // indirect github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 // indirect github.com/emicklei/go-restful/v3 v3.11.0 // indirect - github.com/evanphx/json-patch v5.6.0+incompatible // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/go-errors/errors v1.4.2 // indirect diff --git a/go.sum b/go.sum index f8aa5dd1f..7855d5581 100644 --- a/go.sum +++ b/go.sum @@ -137,8 +137,8 @@ github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymF github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/evanphx/json-patch v5.6.0+incompatible h1:jBYDEEiFBPxA0v50tFdvOzQQTCvpL6mnFh5mB2/l16U= -github.com/evanphx/json-patch v5.6.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch v5.9.0+incompatible h1:fBXyNpNMuTTDdquAq/uisOr2lShz4oaXpDTX2bLe7ls= +github.com/evanphx/json-patch v5.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.0 h1:kcBlZQbplgElYIlo/n1hJbls2z/1awpXxpRi0/FOJfg= github.com/evanphx/json-patch/v5 v5.9.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= @@ -177,6 +177,8 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw= +github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= @@ -390,6 +392,8 @@ github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPx github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sosedoff/gitkit v0.4.0 h1:opyQJ/h9xMRLsz2ca/2CRXtstePcpldiZN8DpLLF8Os= +github.com/sosedoff/gitkit v0.4.0/go.mod h1:V3EpGZ0nvCBhXerPsbDeqtyReNb48cwP9KtkUYTKT5I= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= @@ -473,6 +477,7 @@ golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnf golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= diff --git a/internal/controller/git/bare_repo.go b/internal/controller/git/bare_repo.go new file mode 100644 index 000000000..999313e8e --- /dev/null +++ b/internal/controller/git/bare_repo.go @@ -0,0 +1,244 @@ +package git + +import ( + "bufio" + "bytes" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + + libExec "github.com/akuity/kargo/internal/exec" +) + +// BareRepo is an interface for interacting with a bare Git repository. +type BareRepo interface { + // AddWorkTree adds a working tree to the repository. The working tree will be + // created at the specified path and will be checked out to the specified ref. + AddWorkTree(path, ref string) (WorkTree, error) + // Close cleans up file system resources used by this repository. This should + // always be called before a repository goes out of scope. + Close() error + // Dir returns an absolute path to the repository. + Dir() string + // HomeDir returns an absolute path to the home directory of the system user + // who has cloned this repo. + HomeDir() string + // RemoveWorkTree removes a working tree from the repository. The working tree + // will be removed from the file system. + RemoveWorkTree(path string) error + // URL returns the remote URL of the repository. + URL() string + // WorkTrees returns a list of working trees associated with the repository. + WorkTrees() ([]WorkTree, error) +} + +// bareRepo is an implementation of the BareRepo interface for interacting with +// a bare Git repository. +type bareRepo struct { + *baseRepo +} + +// BareCloneOptions represents options for cloning a Git repository without a +// working tree. +type BareCloneOptions struct { + // BaseDir is an existing directory within which all other directories created + // and managed by the BareRepo implementation will be created. If not + // specified, the operating system's temporary directory will be used. + // Overriding that default is useful under certain circumstances. + BaseDir string + // InsecureSkipTLSVerify specifies whether certificate verification errors + // should be ignored when cloning the repository. The setting will be + // remembered for subsequent interactions with the remote repository. + InsecureSkipTLSVerify bool +} + +// CloneBare produces a local, bare clone of the remote Git repository at the +// specified URL and returns an implementation of the BareRepo interface that is +// stateful and NOT suitable for use across multiple goroutines. This function +// will also perform any setup that is required for successfully authenticating +// to the remote repository. +func CloneBare( + repoURL string, + clientOpts *ClientOptions, + cloneOpts *BareCloneOptions, +) (BareRepo, error) { + if clientOpts == nil { + clientOpts = &ClientOptions{} + } + if cloneOpts == nil { + cloneOpts = &BareCloneOptions{} + } + homeDir, err := os.MkdirTemp(cloneOpts.BaseDir, "repo-") + if err != nil { + return nil, + fmt.Errorf("error creating home directory for repo %q: %w", repoURL, err) + } + if homeDir, err = filepath.EvalSymlinks(homeDir); err != nil { + return nil, + fmt.Errorf("error resolving symlinks in path %s: %w", homeDir, err) + } + b := &bareRepo{ + baseRepo: &baseRepo{ + creds: clientOpts.Credentials, + dir: filepath.Join(homeDir, "repo"), + homeDir: homeDir, + insecureSkipTLSVerify: cloneOpts.InsecureSkipTLSVerify, + url: repoURL, + }, + } + if err = b.setupClient(clientOpts); err != nil { + return nil, err + } + if err = b.clone(); err != nil { + return nil, err + } + if err = b.saveDirs(); err != nil { + return nil, err + } + return b, nil +} + +func (b *bareRepo) clone() error { + cmd := b.buildGitCommand("clone", "--bare", b.url, b.dir) + cmd.Dir = b.homeDir // Override the cmd.Dir that's set by r.buildGitCommand() + if _, err := libExec.Exec(cmd); err != nil { + return fmt.Errorf("error cloning repo %q into %q: %w", b.url, b.dir, err) + } + return nil +} + +type LoadBareRepoOptions struct { + Credentials *RepoCredentials + InsecureSkipTLSVerify bool +} + +func LoadBareRepo(path string, opts *LoadBareRepoOptions) (BareRepo, error) { + if opts == nil { + opts = &LoadBareRepoOptions{} + } + b := &bareRepo{ + baseRepo: &baseRepo{ + creds: opts.Credentials, + dir: path, + insecureSkipTLSVerify: opts.InsecureSkipTLSVerify, + }, + } + if err := b.loadHomeDir(); err != nil { + return nil, fmt.Errorf("error reading repo home dir from config: %w", err) + } + if err := b.loadURL(); err != nil { + return nil, + fmt.Errorf(`error reading URL of remote "origin" from config: %w`, err) + } + if err := b.setupAuth(); err != nil { + return nil, fmt.Errorf("error configuring the credentials: %w", err) + } + return b, nil +} + +func (b *bareRepo) AddWorkTree(path, ref string) (WorkTree, error) { + path, err := filepath.Abs(path) + if err != nil { + return nil, fmt.Errorf("error resolving absolute path for %s: %w", path, err) + } + workTreePaths, err := b.workTrees() + if err != nil { + return nil, err + } + if slices.Contains(workTreePaths, path) { + return nil, fmt.Errorf("working tree already exists at %q", path) + } + if _, err = libExec.Exec( + b.buildGitCommand("worktree", "add", path, ref), + ); err != nil { + return nil, fmt.Errorf("error adding working tree at %q: %w", path, err) + } + if path, err = filepath.EvalSymlinks(path); err != nil { + return nil, fmt.Errorf("error resolving symlinks in path %s: %w", path, err) + } + return &workTree{ + baseRepo: &baseRepo{ + creds: b.creds, + dir: path, + homeDir: b.homeDir, + insecureSkipTLSVerify: b.insecureSkipTLSVerify, + url: b.url, + }, + bareRepo: b, + }, nil +} + +func (b *bareRepo) Close() error { + workTreePaths, err := b.workTrees() + if err != nil { + return err + } + for _, workTreePath := range workTreePaths { + if err := b.RemoveWorkTree(workTreePath); err != nil { + return err + } + } + return os.RemoveAll(b.homeDir) +} + +func (b *bareRepo) RemoveWorkTree(path string) error { + workTreePaths, err := b.workTrees() + if err != nil { + return err + } + if !slices.Contains(workTreePaths, path) { + return fmt.Errorf("no working tree exists at %q", path) + } + if _, err := libExec.Exec( + b.buildGitCommand("worktree", "remove", path), + ); err != nil { + return fmt.Errorf("error removing working tree at %q: %w", path, err) + } + if err := os.RemoveAll(path); err != nil { + return fmt.Errorf("error removing working tree at %q: %w", path, err) + } + return nil +} + +func (b *bareRepo) WorkTrees() ([]WorkTree, error) { + workTreePaths, err := b.workTrees() + if err != nil { + return nil, err + } + workTrees := make([]WorkTree, len(workTreePaths)) + for i, workTreePath := range workTreePaths { + workTrees[i] = &workTree{ + baseRepo: &baseRepo{ + creds: b.creds, + dir: workTreePath, + homeDir: b.homeDir, + insecureSkipTLSVerify: b.insecureSkipTLSVerify, + url: b.url, + }, + bareRepo: b, + } + } + return workTrees, err +} + +func (b *bareRepo) workTrees() ([]string, error) { + res, err := libExec.Exec(b.buildGitCommand("worktree", "list")) + if err != nil { + return nil, fmt.Errorf("error listing working trees: %w", err) + } + workTrees := []string{} + scanner := bufio.NewScanner(bytes.NewReader(res)) + for scanner.Scan() { + line := scanner.Text() + if !strings.HasSuffix(line, "(bare)") { + fields := strings.Fields(line) + if len(fields) != 3 { + return nil, fmt.Errorf("unexpected number of fields: %q", line) + } + workTrees = append(workTrees, fields[0]) + } + } + return workTrees, err +} diff --git a/internal/controller/git/bare_repo_test.go b/internal/controller/git/bare_repo_test.go new file mode 100644 index 000000000..bd9cf7417 --- /dev/null +++ b/internal/controller/git/bare_repo_test.go @@ -0,0 +1,159 @@ +package git + +import ( + "fmt" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + + "github.com/google/uuid" + "github.com/sosedoff/gitkit" + "github.com/stretchr/testify/require" + + "github.com/akuity/kargo/internal/types" +) + +func TestBareRepo(t *testing.T) { + testRepoCreds := RepoCredentials{ + Username: "fake-username", + Password: "fake-password", + } + + // This will be something to opt into because on some OSes, this will lead + // to keychain-related prompts. + var useAuth bool + if useAuthStr := os.Getenv("TEST_GIT_CLIENT_WITH_AUTH"); useAuthStr != "" { + useAuth = types.MustParseBool(useAuthStr) + } + service := gitkit.New( + gitkit.Config{ + Dir: t.TempDir(), + AutoCreate: true, + Auth: useAuth, + }, + ) + require.NoError(t, service.Setup()) + service.AuthFunc = + func(cred gitkit.Credential, _ *gitkit.Request) (bool, error) { + return cred.Username == testRepoCreds.Username && + cred.Password == testRepoCreds.Password, nil + } + server := httptest.NewServer(service) + defer server.Close() + + testRepoURL := fmt.Sprintf("%s/test.git", server.URL) + + setupRep, err := Clone( + testRepoURL, + &ClientOptions{ + Credentials: &testRepoCreds, + }, + nil, + ) + require.NoError(t, err) + require.NotNil(t, setupRep) + defer setupRep.Close() + err = os.WriteFile(fmt.Sprintf("%s/%s", setupRep.Dir(), "test.txt"), []byte("foo"), 0600) + require.NoError(t, err) + err = setupRep.AddAllAndCommit(fmt.Sprintf("initial commit %s", uuid.NewString())) + require.NoError(t, err) + err = setupRep.Push(false) + require.NoError(t, err) + err = setupRep.Close() + require.NoError(t, err) + + rep, err := CloneBare( + testRepoURL, + &ClientOptions{ + Credentials: &testRepoCreds, + }, + nil, + ) + require.NoError(t, err) + require.NotNil(t, rep) + defer rep.Close() + r, ok := rep.(*bareRepo) + require.True(t, ok) + + t.Run("can clone", func(t *testing.T) { + var repoURL *url.URL + repoURL, err = url.Parse(r.url) + require.NoError(t, err) + repoURL.User = nil + require.Equal(t, testRepoURL, repoURL.String()) + require.NotEmpty(t, r.homeDir) + var fi os.FileInfo + fi, err = os.Stat(r.homeDir) + require.NoError(t, err) + require.True(t, fi.IsDir()) + require.NotEmpty(t, r.dir) + fi, err = os.Stat(r.dir) + require.NoError(t, err) + require.True(t, fi.IsDir()) + }) + + t.Run("can get the repo url", func(t *testing.T) { + require.Equal(t, r.url, r.URL()) + }) + + t.Run("can get the home dir", func(t *testing.T) { + require.Equal(t, r.homeDir, r.HomeDir()) + }) + + t.Run("can get the working tree dir", func(t *testing.T) { + require.Equal(t, r.dir, r.Dir()) + }) + + workingTreePath := filepath.Join(rep.HomeDir(), "working-tree") + // "master" is still the default branch name for a new repository unless + // you configure it otherwise. + workTree, err := rep.AddWorkTree(workingTreePath, "master") + require.NoError(t, err) + defer workTree.Close() + + t.Run("add a working tree", func(t *testing.T) { + require.NoError(t, err) + require.Equal(t, workingTreePath, workTree.Dir()) + _, err = os.Stat(workTree.Dir()) + require.NoError(t, err) + }) + + t.Run("can list working trees", func(t *testing.T) { + var workTrees []WorkTree + workTrees, err = rep.WorkTrees() + require.NoError(t, err) + require.Len(t, workTrees, 1) + require.Equal(t, workTree, workTrees[0]) + }) + + t.Run("can remove a working tree", func(t *testing.T) { + err = rep.RemoveWorkTree(workTree.Dir()) + require.NoError(t, err) + trees, err := rep.WorkTrees() + require.NoError(t, err) + require.Len(t, trees, 0) + _, err = os.Stat(workTree.Dir()) + require.True(t, os.IsNotExist(err)) + }) + + t.Run("can load an existing repo", func(t *testing.T) { + existingRepo, err := LoadBareRepo( + rep.Dir(), + &LoadBareRepoOptions{ + Credentials: &testRepoCreds, + }, + ) + require.NoError(t, err) + require.Equal(t, rep, existingRepo) + }) + + t.Run("can close repo", func(t *testing.T) { + require.NoError(t, rep.Close()) + _, err := os.Stat(rep.HomeDir()) + require.Error(t, err) + require.True(t, os.IsNotExist(err)) + }) + +} diff --git a/internal/controller/git/base_repo.go b/internal/controller/git/base_repo.go new file mode 100644 index 000000000..f3213f13d --- /dev/null +++ b/internal/controller/git/base_repo.go @@ -0,0 +1,242 @@ +package git + +import ( + "fmt" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + + libExec "github.com/akuity/kargo/internal/exec" +) + +// baseRepo implements the common underpinnings of a Git repository with a +// single working tree, a bare repository, or working tree associated with a +// bare repository. +type baseRepo struct { + creds *RepoCredentials + dir string + homeDir string + insecureSkipTLSVerify bool + url string +} + +// ClientOptions represents options for a repository-specific Git client. +type ClientOptions struct { + // User represents the actor that performs operations against the git + // repository. This has no effect on authentication, see Credentials for + // specifying authentication configuration. + User *User + // Credentials represents the authentication information. + Credentials *RepoCredentials +} + +// setupClient configures the git CLI for authentication using either SSH or +// the "store" (username/password-based) credential helper. +func (b *baseRepo) setupClient(opts *ClientOptions) error { + if opts == nil { + opts = &ClientOptions{} + } + + if err := b.setupAuthor(opts.User); err != nil { + return fmt.Errorf("error configuring the author: %w", err) + } + + if err := b.setupAuth(); err != nil { + return fmt.Errorf("error configuring the credentials: %w", err) + } + + return nil +} + +// User represents the user contributing to a git repository. +type User struct { + // Name is the user's full name. + Name string + // Email is the user's email address. + Email string + // SigningKeyType indicates the type of signing key. + SigningKeyType SigningKeyType + // SigningKeyPath is an optional path referencing a signing key for + // signing git objects. + SigningKeyPath string +} + +// setupAuthor configures the git CLI with a default commit author. +// Optionally, the author can have an associated signing key. When using GPG +// signing, the name and email must match the GPG key identity. +func (b *baseRepo) setupAuthor(author *User) error { + if author == nil { + author = &User{} + } + + if author.Name == "" { + author.Name = "Kargo" + } + + cmd := b.buildGitCommand("config", "--global", "user.name", author.Name) + cmd.Dir = b.homeDir // Override the cmd.Dir that's set by r.buildGitCommand() + if _, err := libExec.Exec(cmd); err != nil { + return fmt.Errorf("error configuring git user name: %w", err) + } + + if author.Email == "" { + author.Name = "kargo@akuity.io" + } + + cmd = b.buildGitCommand("config", "--global", "user.email", author.Email) + cmd.Dir = b.homeDir // Override the cmd.Dir that's set by r.buildGitCommand() + if _, err := libExec.Exec(cmd); err != nil { + return fmt.Errorf("error configuring git user email: %w", err) + } + + if author.SigningKeyPath != "" && author.SigningKeyType == SigningKeyTypeGPG { + cmd = b.buildGitCommand("config", "--global", "commit.gpgsign", "true") + cmd.Dir = b.homeDir // Override the cmd.Dir that's set by r.buildGitCommand() + if _, err := libExec.Exec(cmd); err != nil { + return fmt.Errorf("error configuring commit gpg signing: %w", err) + } + + cmd = b.buildCommand("gpg", "--import", author.SigningKeyPath) + cmd.Dir = b.homeDir // Override the cmd.Dir that's set by r.buildCommand() + if _, err := libExec.Exec(cmd); err != nil { + return fmt.Errorf("error importing gpg key %q: %w", author.SigningKeyPath, err) + } + } + + return nil +} + +func (b *baseRepo) setupAuth() error { + if b.creds == nil { + return nil + } + // If an SSH key was provided, use that. + if b.creds.SSHPrivateKey != "" { + sshPath := filepath.Join(b.homeDir, ".ssh") + if err := os.Mkdir(sshPath, 0700); err != nil { + return fmt.Errorf("error creating SSH directory %q: %w", sshPath, err) + } + sshConfigPath := filepath.Join(sshPath, "config") + rsaKeyPath := filepath.Join(sshPath, "id_rsa") + // nolint: lll + sshConfig := fmt.Sprintf("Host *\n StrictHostKeyChecking no\n UserKnownHostsFile=/dev/null\n IdentityFile %q\n", rsaKeyPath) + if err := + os.WriteFile(sshConfigPath, []byte(sshConfig), 0600); err != nil { + return fmt.Errorf("error writing SSH config to %q: %w", sshConfigPath, err) + } + + if err := os.WriteFile( + rsaKeyPath, + []byte(b.creds.SSHPrivateKey), + 0600, + ); err != nil { + return fmt.Errorf("error writing SSH key to %q: %w", rsaKeyPath, err) + } + return nil // We're done + } + + // If no password is specified, we're done'. + if b.creds.Password == "" { + return nil + } + + lowerURL := strings.ToLower(b.url) + if strings.HasPrefix(lowerURL, "http://") || strings.HasPrefix(lowerURL, "https://") { + u, err := url.Parse(b.url) + if err != nil { + return fmt.Errorf("error parsing URL %q: %w", b.url, err) + } + u.User = url.User(b.creds.Username) + b.url = u.String() + } + + return nil +} + +// saveDirs saves information about the repository's directories to the +// repository's configuration. This is useful for reliably determining this +// information later if an existing repository or working tree is loaded from +// the file system. +func (b *baseRepo) saveDirs() error { + if _, err := libExec.Exec(b.buildGitCommand( + "config", + "kargo.repoDir", + b.dir, + )); err != nil { + return fmt.Errorf("error saving repo dir as config: %w", err) + } + if _, err := libExec.Exec(b.buildGitCommand( + "config", + "kargo.repoHomeDir", + b.homeDir, + )); err != nil { + return fmt.Errorf("error saving repo home dir as config: %w", err) + } + return nil +} + +// loadHomeDir restores the repository's home directory from the repository's +// configuration. This is useful for reliably determining this information when +// an existing repository or working tree is loaded from the file system. +func (b *baseRepo) loadHomeDir() error { + res, err := libExec.Exec(b.buildGitCommand( + "config", + "kargo.repoHomeDir", + )) + if err != nil { + return fmt.Errorf("error reading repo home dir from config: %w", err) + } + b.homeDir = strings.TrimSpace(string(res)) + return nil +} + +func (b *baseRepo) loadURL() error { + res, err := libExec.Exec(b.buildGitCommand("config", "remote.origin.url")) + if err != nil { + return fmt.Errorf(`error getting URL of remote "origin": %w`, err) + } + b.url = strings.TrimSpace(string(res)) + return nil +} + +func (b *baseRepo) buildCommand(command string, arg ...string) *exec.Cmd { + cmd := exec.Command(command, arg...) + homeEnvVar := fmt.Sprintf("HOME=%s", b.homeDir) + if cmd.Env == nil { + cmd.Env = []string{homeEnvVar} + } else { + cmd.Env = append(cmd.Env, homeEnvVar) + } + cmd.Dir = b.dir + return cmd +} + +func (b *baseRepo) buildGitCommand(arg ...string) *exec.Cmd { + cmd := b.buildCommand("git", arg...) + cmd.Env = append(cmd.Env, fmt.Sprintf("GIT_SSH_COMMAND=ssh -F %s/.ssh/config", b.homeDir)) + if b.creds != nil && b.creds.Password != "" { + cmd.Env = append( + cmd.Env, + "GIT_ASKPASS=/usr/local/bin/credential-helper", + fmt.Sprintf("GIT_PASSWORD=%s", b.creds.Password), + ) + } + if b.insecureSkipTLSVerify { + cmd.Env = append(cmd.Env, "GIT_SSL_NO_VERIFY=true") + } + return cmd +} + +func (b *baseRepo) Dir() string { + return b.dir +} + +func (b *baseRepo) HomeDir() string { + return b.homeDir +} + +func (b *baseRepo) URL() string { + return b.url +} diff --git a/internal/controller/git/client_opts.go b/internal/controller/git/client_opts.go new file mode 100644 index 000000000..cd99cdbee --- /dev/null +++ b/internal/controller/git/client_opts.go @@ -0,0 +1 @@ +package git diff --git a/internal/controller/git/commit_signing.go b/internal/controller/git/commit_signing.go new file mode 100644 index 000000000..a5455677f --- /dev/null +++ b/internal/controller/git/commit_signing.go @@ -0,0 +1,5 @@ +package git + +type SigningKeyType string + +const SigningKeyTypeGPG SigningKeyType = "gpg" diff --git a/internal/controller/git/consts.go b/internal/controller/git/consts.go new file mode 100644 index 000000000..9f7209b3f --- /dev/null +++ b/internal/controller/git/consts.go @@ -0,0 +1,16 @@ +package git + +// FilterBlobless is a filter that excludes blobs from the clone. When using +// this filter, the initial Git clone will download all reachable commits and +// trees, and only download the blobs for commits when you do a Git checkout +// (including the first checkout during the clone). +// +// When using a blobless clone, you can still explore the commits of the +// repository without downloading additional data. This means that you can +// perform commands like `git log`, or even `git log -- ` with the same +// performance as a full clone. +// +// Commands like `git diff` or `git blame ` require the contents of the +// paths to compute diffs, so these will trigger blob downloads the first time +// they are run. +const FilterBlobless = "blob:none" diff --git a/internal/controller/git/credentials.go b/internal/controller/git/credentials.go new file mode 100644 index 000000000..77da6b00f --- /dev/null +++ b/internal/controller/git/credentials.go @@ -0,0 +1,17 @@ +package git + +// RepoCredentials represents the credentials for connecting to a private git +// repository. +type RepoCredentials struct { + // SSHPrivateKey is a private key that can be used for both reading from and + // writing to some remote repository. + SSHPrivateKey string `json:"sshPrivateKey,omitempty"` + // Username identifies a principal, which combined with the value of the + // Password field, can be used for both reading from and writing to some + // remote repository. + Username string `json:"username,omitempty"` + // Password, when combined with the principal identified by the Username + // field, can be used for both reading from and writing to some remote + // repository. + Password string `json:"password,omitempty"` +} diff --git a/internal/controller/git/git.go b/internal/controller/git/git.go deleted file mode 100644 index 293d02c58..000000000 --- a/internal/controller/git/git.go +++ /dev/null @@ -1,770 +0,0 @@ -package git - -import ( - "bufio" - "bytes" - "errors" - "fmt" - "net/url" - "os" - "os/exec" - "path/filepath" - "strings" - "time" - - libExec "github.com/akuity/kargo/internal/exec" -) - -// RepoCredentials represents the credentials for connecting to a private git -// repository. -type RepoCredentials struct { - // SSHPrivateKey is a private key that can be used for both reading from and - // writing to some remote repository. - SSHPrivateKey string `json:"sshPrivateKey,omitempty"` - // Username identifies a principal, which combined with the value of the - // Password field, can be used for both reading from and writing to some - // remote repository. - Username string `json:"username,omitempty"` - // Password, when combined with the principal identified by the Username - // field, can be used for both reading from and writing to some remote - // repository. - Password string `json:"password,omitempty"` -} - -type SigningKeyType string - -const ( - SigningKeyTypeGPG SigningKeyType = "gpg" -) - -// User represents the user contributing to a git repository. -type User struct { - // Name is the user's full name. - Name string - // Email is the user's email address. - Email string - // SigningKeyType indicates the type of signing key. - SigningKeyType SigningKeyType - // SigningKeyPath is an optional path referencing a signing key for - // signing git objects. - SigningKeyPath string -} - -// CommitOptions represents options for committing changes to a git repository. -type CommitOptions struct { - // AllowEmpty indicates whether an empty commit should be allowed. - AllowEmpty bool -} - -// TagMetadata represents metadata associated with a Git tag. -type TagMetadata struct { - // Tag is the name of the tag. - Tag string - // CommitID is the ID (sha) of the commit associated with the tag. - CommitID string - // CreatorDate is the creation date of an annotated tag, or the commit date - // of a lightweight tag. - CreatorDate time.Time - // Author is the author of the commit message associated with the tag, in - // the format "Name ". - Author string - // Committer is the person who committed the commit associated with the tag, - // in the format "Name ". - Committer string - // Subject is the subject (first line) of the commit message associated - // with the tag. - Subject string -} - -type CommitMetadata struct { - // CommitID is the ID (sha) of the commit. - ID string - // CommitDate is the date of the commit. - CommitDate time.Time - // Author is the author of the commit, in the format "Name ". - Author string - // Committer is the person who committed the commit, in the format - // "Name ". - Committer string - // Subject is the subject (first line) of the commit message. - Subject string -} - -// Repo is an interface for interacting with a git repository. -type Repo interface { - // AddAll stages pending changes for commit. - AddAll() error - // AddAllAndCommit is a convenience function that stages pending changes for - // commit to the current branch and then commits them using the provided - // commit message. - AddAllAndCommit(message string) error - // Clean cleans the working directory. - Clean() error - // Close cleans up file system resources used by this repository. This should - // always be called before a repository goes out of scope. - Close() error - // Checkout checks out the specified branch. - Checkout(branch string) error - // Commit commits staged changes to the current branch. - Commit(message string, opts *CommitOptions) error - // CreateChildBranch creates a new branch that is a child of the current - // branch. - CreateChildBranch(branch string) error - // CreateOrphanedBranch creates a new branch that shares no commit history - // with any other branch. - CreateOrphanedBranch(branch string) error - // CurrentBranch returns the current branch - CurrentBranch() string - // DeleteBranch deletes the specified branch - DeleteBranch(branch string) error - // HasDiffs returns a bool indicating whether the working directory currently - // contains any differences from what's already at the head of the current - // branch. - HasDiffs() (bool, error) - // GetDiffPathsForCommitID returns a string slice indicating the paths, - // relative to the root of the repository, of any files that are new or - // modified in the commit with the given ID. - GetDiffPathsForCommitID(commitID string) ([]string, error) - // IsAncestor returns true if parent branch is an ancestor of child - IsAncestor(parent string, child string) (bool, error) - // LastCommitID returns the ID (sha) of the most recent commit to the current - // branch. - LastCommitID() (string, error) - // ListTags returns a slice of tags in the repository with metadata such as - // commit ID, creator date, and subject. - ListTags() ([]TagMetadata, error) - // ListCommits returns a slice of commits in the current branch with - // metadata such as commit ID, commit date, and subject. - ListCommits(limit, skip uint) ([]CommitMetadata, error) - // CommitMessage returns the text of the most recent commit message associated - // with the specified commit ID. - CommitMessage(id string) (string, error) - // Push pushes from the current branch to a remote branch by the same name. - Push(force bool) error - // RefsHaveDiffs returns whether there is a diff between two commits/branches - RefsHaveDiffs(commit1 string, commit2 string) (bool, error) - // RemoteBranchExists returns a bool indicating if the specified branch exists - // in the remote repository. - RemoteBranchExists(branch string) (bool, error) - // ResetHard performs a hard reset. - ResetHard() error - // URL returns the remote URL of the repository. - URL() string - // WorkingDir returns an absolute path to the repository's working tree. - WorkingDir() string - // HomeDir returns an absolute path to the home directory of the system user - // who has cloned this repo. - HomeDir() string -} - -// repo is an implementation of the Repo interface for interacting with a git -// repository. -type repo struct { - url string - homeDir string - dir string - currentBranch string - insecureSkipTLSVerify bool -} - -// ClientOptions represents options for the git client. Commonly, the -// repository credentials are required to authenticate with a remote -// repository. -type ClientOptions struct { - // User represents the actor that performs operations against the git - // repository. This has no effect on authentication, see Credentials for - // specifying authentication configuration. - User *User - // Credentials represents the authentication information. - Credentials *RepoCredentials -} - -const ( - // FilterBlobless is a filter that excludes blobs from the clone. - // When using this filter, the initial Git clone will download all - // reachable commits and trees, and only download the blobs for commits - // when you do a Git checkout (including the first checkout during the - // clone). - // - // When using a blobless clone, you can still explore the commits of the - // repository without downloading additional data. This means that you can - // perform commands like `git log`, or even `git log -- ` with the - // same performance as a full clone. - // - // Commands like `git diff` or `git blame ` require the contents of - // the paths to compute diffs, so these will trigger blob downloads the - // first time they are run. - FilterBlobless = "blob:none" -) - -// CloneOptions represents options for cloning a git repository. -type CloneOptions struct { - // Branch is the name of the branch to clone. If not specified, the default - // branch will be cloned. - Branch string - // SingleBranch indicates whether the clone should be a single-branch clone. - SingleBranch bool - // Depth is the number of commits to fetch from the remote repository. If - // zero, all commits will be fetched. - Depth uint - // Filter allows for partially cloning the repository by specifying a - // filter. When a filter is specified, the server will only send a - // subset of reachable objects according to a given object filter. - // - // For more information, see: - // - https://git-scm.com/docs/git-clone#Documentation/git-clone.txt-code--filtercodeemltfilter-specgtem - // - https://git-scm.com/docs/git-rev-list#Documentation/git-rev-list.txt---filterltfilter-specgt - // - https://github.blog/2020-12-21-get-up-to-speed-with-partial-clone-and-shallow-clone/ - // - https://docs.gitlab.com/ee/topics/git/partial_clone.html - Filter string - // InsecureSkipTLSVerify specifies whether certificate verification errors - // should be ignored when cloning the repository. The setting will be - // remembered for subsequent interactions with the remote repository. - InsecureSkipTLSVerify bool -} - -// Clone produces a local clone of the remote git repository at the specified -// URL and returns an implementation of the Repo interface that is stateful and -// NOT suitable for use across multiple goroutines. This function will also -// perform any setup that is required for successfully authenticating to the -// remote repository. -func Clone( - repoURL string, - clientOpts *ClientOptions, - cloneOpts *CloneOptions, -) (Repo, error) { - homeDir, err := os.MkdirTemp("", "repo-") - if err != nil { - return nil, fmt.Errorf("error creating home directory for repo %q: %w", repoURL, err) - } - r := &repo{ - url: repoURL, - homeDir: homeDir, - dir: filepath.Join(homeDir, "repo"), - insecureSkipTLSVerify: cloneOpts.InsecureSkipTLSVerify, - } - if err = r.setupClient(clientOpts); err != nil { - return nil, err - } - return r, r.clone(cloneOpts) -} - -func (r *repo) AddAll() error { - if _, err := libExec.Exec(r.buildGitCommand("add", ".")); err != nil { - return fmt.Errorf("error staging changes for commit: %w", err) - } - return nil -} - -func (r *repo) AddAllAndCommit(message string) error { - if err := r.AddAll(); err != nil { - return err - } - return r.Commit(message, nil) -} - -func (r *repo) Clean() error { - if _, err := libExec.Exec(r.buildGitCommand("clean", "-fd")); err != nil { - return fmt.Errorf("error cleaning branch %q: %w", r.currentBranch, err) - } - return nil -} - -func (r *repo) clone(opts *CloneOptions) error { - if opts == nil { - opts = &CloneOptions{} - } - args := []string{"clone", "--no-tags"} - if opts.Branch != "" { - args = append(args, "--branch", opts.Branch) - r.currentBranch = opts.Branch - } - if opts.SingleBranch { - args = append(args, "--single-branch") - } - if opts.Depth > 0 { - args = append(args, "--depth", fmt.Sprint(opts.Depth)) - } - args = append(args, r.url, r.dir) - cmd := r.buildGitCommand(args...) - cmd.Dir = r.homeDir // Override the cmd.Dir that's set by r.buildGitCommand() - if _, err := libExec.Exec(cmd); err != nil { - return fmt.Errorf("error cloning repo %q into %q: %w", r.url, r.dir, err) - } - if opts.Branch == "" { - // If branch wasn't specified as part of options, we need to determine it manually - resBytes, err := libExec.Exec(r.buildGitCommand( - "branch", - "--show-current", - )) - if err != nil { - return fmt.Errorf("error determining branch after cloning: %w", err) - } - r.currentBranch = strings.TrimSpace(string(resBytes)) - } - return nil -} - -func (r *repo) Close() error { - return os.RemoveAll(r.homeDir) -} - -func (r *repo) Checkout(branch string) error { - r.currentBranch = branch - if _, err := libExec.Exec(r.buildGitCommand( - "checkout", - branch, - // The next line makes it crystal clear to git that we're checking out - // a branch. We need to do this because branch names can often resemble - // paths within the repo. - "--", - )); err != nil { - return fmt.Errorf("error checking out branch %q from repo %q: %w", branch, r.url, err) - } - return nil -} - -func (r *repo) Commit(message string, opts *CommitOptions) error { - if opts == nil { - opts = &CommitOptions{} - } - cmdTokens := []string{"commit", "-m", message} - if opts.AllowEmpty { - cmdTokens = append(cmdTokens, "--allow-empty") - } - - if _, err := libExec.Exec(r.buildGitCommand(cmdTokens...)); err != nil { - return fmt.Errorf("error committing changes to branch %q: %w", r.currentBranch, err) - } - return nil -} - -func (r *repo) RefsHaveDiffs(commit1 string, commit2 string) (bool, error) { - // `git diff --quiet` returns 0 if no diff, 1 if diff, and non-zero/one for any other error - _, err := libExec.Exec(r.buildGitCommand( - "diff", "--quiet", fmt.Sprintf("%s..%s", commit1, commit2), "--")) - if err == nil { - return false, nil - } - var execErr *libExec.ExitError - if errors.As(err, &execErr) { - if execErr.ExitCode == 1 { - return true, nil - } - } - return false, fmt.Errorf("error diffing commits %s..%s: %w", commit1, commit2, err) -} - -func (r *repo) CreateChildBranch(branch string) error { - r.currentBranch = branch - if _, err := libExec.Exec(r.buildGitCommand( - "checkout", - "-b", - branch, - // The next line makes it crystal clear to git that we're checking out - // a branch. We need to do this because branch names can often resemble - // paths within the repo. - "--", - )); err != nil { - return fmt.Errorf("error creating new branch %q for repo %q: %w", branch, r.url, err) - } - return nil -} - -func (r *repo) CreateOrphanedBranch(branch string) error { - r.currentBranch = branch - if _, err := libExec.Exec(r.buildGitCommand( - "switch", - "--orphan", - branch, - "--discard-changes", - )); err != nil { - return fmt.Errorf("error creating orphaned branch %q for repo %q: %w", branch, r.url, err) - } - return r.Clean() -} - -func (r *repo) CurrentBranch() string { - return r.currentBranch -} - -func (r *repo) DeleteBranch(branch string) error { - if _, err := libExec.Exec(r.buildGitCommand( - "branch", - "--delete", - "--force", - branch, - )); err != nil { - return fmt.Errorf("error deleting branch %q for repo %q: %w", branch, r.url, err) - } - return nil -} - -func (r *repo) HasDiffs() (bool, error) { - resBytes, err := libExec.Exec(r.buildGitCommand("status", "-s")) - if err != nil { - return false, fmt.Errorf("error checking status of branch %q: %w", r.currentBranch, err) - } - return len(resBytes) > 0, nil -} - -func (r *repo) GetDiffPathsForCommitID(commitID string) ([]string, error) { - resBytes, err := libExec.Exec(r.buildGitCommand("show", "--pretty=", "--name-only", commitID)) - if err != nil { - return nil, fmt.Errorf("error getting diff paths for commit %q: %w", commitID, err) - } - var paths []string - scanner := bufio.NewScanner(bytes.NewReader(resBytes)) - scanner.Split(bufio.ScanLines) - for scanner.Scan() { - paths = append( - paths, - scanner.Text(), - ) - } - return paths, nil -} - -func (r *repo) IsAncestor(parent string, child string) (bool, error) { - _, err := libExec.Exec(r.buildGitCommand("merge-base", "--is-ancestor", parent, child)) - if err == nil { - return true, nil - } - var execErr *libExec.ExitError - if errors.As(err, &execErr) { - if execErr.ExitCode == 1 { - return false, nil - } - } - return false, fmt.Errorf("error testing ancestry of branches %q, %q: %w", parent, child, err) -} - -func (r *repo) LastCommitID() (string, error) { - shaBytes, err := libExec.Exec(r.buildGitCommand("rev-parse", "HEAD")) - if err != nil { - return "", fmt.Errorf("error obtaining ID of last commit: %w", err) - } - return strings.TrimSpace(string(shaBytes)), nil -} - -func (r *repo) ListTags() ([]TagMetadata, error) { - if _, err := libExec.Exec(r.buildGitCommand("fetch", "origin", "--tags")); err != nil { - return nil, fmt.Errorf("error fetching tags from repo %q: %w", r.url, err) - } - - // These formats are quite complex, so we break them down into smaller - // pieces for readability. - // - // They are designed to output the following fields, separated by `|*|`: - // - tag name - // - commit ID - // - subject - // - author name and email - // - committer name and email - // - creator date - // - // The `if`/`then`/`else` logic is used to ensure that we get the commit ID - // and subject of the tag, regardless of whether it's an annotated or - // lightweight tag. - // - // nolint: lll - const ( - formatAnnotatedTag = `%(refname:short)|*|%(*objectname)|*|%(*contents:subject)|*|%(*authorname) %(*authoremail)|*|%(*committername) %(*committeremail)|*|%(*creatordate:iso8601)` - formatLightweightTag = `%(refname:short)|*|%(objectname)|*|%(contents:subject)|*|%(authorname) %(authoremail)|*|%(committername) %(committeremail)|*|%(creatordate:iso8601)` - tagFormat = `%(if)%(*objectname)%(then)` + formatAnnotatedTag + `%(else)` + formatLightweightTag + `%(end)` - ) - - tagsBytes, err := libExec.Exec(r.buildGitCommand( - "for-each-ref", - "--sort=-creatordate", - "--format="+tagFormat, - "refs/tags", - )) - if err != nil { - return nil, fmt.Errorf("error listing tags for repo %q: %w", r.url, err) - } - - var tags []TagMetadata - scanner := bufio.NewScanner(bytes.NewReader(tagsBytes)) - for scanner.Scan() { - line := scanner.Bytes() - parts := bytes.SplitN(scanner.Bytes(), []byte("|*|"), 6) - if len(parts) != 6 { - return nil, fmt.Errorf("unexpected number of fields: %q", line) - } - - creatorDate, err := time.Parse("2006-01-02 15:04:05 -0700", string(parts[5])) - if err != nil { - return nil, fmt.Errorf("error parsing creator date %q: %w", parts[5], err) - } - - tags = append(tags, TagMetadata{ - Tag: string(parts[0]), - CommitID: string(parts[1]), - Subject: string(parts[2]), - Author: string(parts[3]), - Committer: string(parts[4]), - CreatorDate: creatorDate, - }) - } - - return tags, nil -} - -func (r *repo) ListCommits(limit, skip uint) ([]CommitMetadata, error) { - args := []string{ - "log", - // This format is designed to output the following fields, separated by - // tabs (%x09): - // - // - commit ID - // - commit date - // - author name and email - // - committer name and email - // - subject - "--pretty=format:%H%x09%ci%x09%an <%ae>%x09%cn <%ce>%x09%s", - } - if limit > 0 { - args = append(args, fmt.Sprintf("--max-count=%d", limit)) - } - if skip > 0 { - args = append(args, fmt.Sprintf("--skip=%d", skip)) - } - - commitsBytes, err := libExec.Exec(r.buildGitCommand(args...)) - if err != nil { - return nil, fmt.Errorf("error listing commits for repo %q: %w", r.url, err) - } - - var commits []CommitMetadata - scanner := bufio.NewScanner(bytes.NewReader(commitsBytes)) - for scanner.Scan() { - line := scanner.Bytes() - parts := bytes.SplitN(scanner.Bytes(), []byte("\t"), 5) - if len(parts) != 5 { - return nil, fmt.Errorf("unexpected number of fields: %q", line) - } - - commitDate, err := time.Parse("2006-01-02 15:04:05 -0700", string(parts[1])) - if err != nil { - return nil, fmt.Errorf("error parsing commit date %q: %w", parts[1], err) - } - - commits = append(commits, CommitMetadata{ - ID: string(parts[0]), - CommitDate: commitDate, - Author: string(parts[2]), - Committer: string(parts[3]), - Subject: string(parts[4]), - }) - } - - return commits, nil -} - -func (r *repo) CommitMessage(id string) (string, error) { - msgBytes, err := libExec.Exec( - r.buildGitCommand("log", "-n", "1", "--pretty=format:%s", id), - ) - if err != nil { - return "", fmt.Errorf("error obtaining commit message for commit %q: %w", id, err) - } - return string(msgBytes), nil -} - -func (r *repo) Push(force bool) error { - args := []string{"push", "origin", r.currentBranch} - if force { - args = append(args, "--force") - } - if _, err := libExec.Exec(r.buildGitCommand(args...)); err != nil { - return fmt.Errorf("error pushing branch %q: %w", r.currentBranch, err) - } - return nil -} - -func (r *repo) RemoteBranchExists(branch string) (bool, error) { - _, err := libExec.Exec(r.buildGitCommand( - "ls-remote", - "--heads", - "--exit-code", // Return 2 if not found - r.url, - branch, - )) - var exitErr *libExec.ExitError - if errors.As(err, &exitErr) && exitErr.ExitCode == 2 { - // Branch does not exist - return false, nil - } - if err != nil { - return false, fmt.Errorf( - "error checking for existence of branch %q in remote repo %q: %w", - branch, - r.url, - err, - ) - } - return true, nil -} - -func (r *repo) ResetHard() error { - if _, err := libExec.Exec(r.buildGitCommand("reset", "--hard")); err != nil { - return fmt.Errorf("error resetting branch working tree: %w", err) - } - return nil -} - -func (r *repo) URL() string { - return r.url -} - -func (r *repo) HomeDir() string { - return r.homeDir -} - -func (r *repo) WorkingDir() string { - return r.dir -} - -// setupClient configures the git CLI for authentication using either SSH or -// the "store" (username/password-based) credential helper. -func (r *repo) setupClient(opts *ClientOptions) error { - if opts == nil { - opts = &ClientOptions{} - } - - if opts.User != nil { - if err := r.setupAuthor(*opts.User); err != nil { - return fmt.Errorf("error configuring the author: %w", err) - } - } - - if opts.Credentials != nil { - if err := r.setupAuth(*opts.Credentials); err != nil { - return fmt.Errorf("error configuring the credentials: %w", err) - } - } - - return nil -} - -// setupAuthor configures the git CLI with a default commit author. -// Optionally, the author can have an associated signing key. When using GPG -// signing, the name and email must match the GPG key identity. -func (r *repo) setupAuthor(author User) error { - if author.Name == "" { - author.Name = "Kargo Render" - } - - cmd := r.buildGitCommand("config", "--global", "user.name", author.Name) - cmd.Dir = r.homeDir // Override the cmd.Dir that's set by r.buildGitCommand() - if _, err := libExec.Exec(cmd); err != nil { - return fmt.Errorf("error configuring git user name: %w", err) - } - - if author.Email == "" { - author.Name = "kargo-render@akuity.io" - } - - cmd = r.buildGitCommand("config", "--global", "user.email", author.Email) - cmd.Dir = r.homeDir // Override the cmd.Dir that's set by r.buildGitCommand() - if _, err := libExec.Exec(cmd); err != nil { - return fmt.Errorf("error configuring git user email: %w", err) - } - - if author.SigningKeyPath != "" && author.SigningKeyType == SigningKeyTypeGPG { - cmd = r.buildGitCommand("config", "--global", "commit.gpgsign", "true") - cmd.Dir = r.homeDir // Override the cmd.Dir that's set by r.buildGitCommand() - if _, err := libExec.Exec(cmd); err != nil { - return fmt.Errorf("error configuring commit gpg signing: %w", err) - } - - cmd = r.buildCommand("gpg", "--import", author.SigningKeyPath) - cmd.Dir = r.homeDir // Override the cmd.Dir that's set by r.buildCommand() - if _, err := libExec.Exec(cmd); err != nil { - return fmt.Errorf("error importing gpg key %q: %w", author.SigningKeyPath, err) - } - } - - return nil -} - -func (r *repo) setupAuth(creds RepoCredentials) error { - // If an SSH key was provided, use that. - if creds.SSHPrivateKey != "" { - sshPath := filepath.Join(r.homeDir, ".ssh") - if err := os.Mkdir(sshPath, 0700); err != nil { - return fmt.Errorf("error creating SSH directory %q: %w", sshPath, err) - } - sshConfigPath := filepath.Join(sshPath, "config") - rsaKeyPath := filepath.Join(sshPath, "id_rsa") - // nolint: lll - sshConfig := fmt.Sprintf("Host *\n StrictHostKeyChecking no\n UserKnownHostsFile=/dev/null\n IdentityFile %q\n", rsaKeyPath) - if err := - os.WriteFile(sshConfigPath, []byte(sshConfig), 0600); err != nil { - return fmt.Errorf("error writing SSH config to %q: %w", sshConfigPath, err) - } - - if err := os.WriteFile( - rsaKeyPath, - []byte(creds.SSHPrivateKey), - 0600, - ); err != nil { - return fmt.Errorf("error writing SSH key to %q: %w", rsaKeyPath, err) - } - return nil // We're done - } - - // If we get to here, we're authenticating using a password - - // Set up the credential helper - cmd := r.buildGitCommand("config", "--global", "credential.helper", "store") - cmd.Dir = r.homeDir // Override the cmd.Dir that's set by r.buildGitCommand() - if _, err := libExec.Exec(cmd); err != nil { - return fmt.Errorf("error configuring git credential helper: %w", err) - } - - credentialURL, err := url.Parse(r.url) - if err != nil { - return fmt.Errorf("error parsing URL %q: %w", r.url, err) - } - // Remove path and query string components from the URL - credentialURL.Path = "" - credentialURL.RawQuery = "" - // If the username is the empty string, we assume we're working with a git - // provider like GitHub that only requires the username to be non-empty. We - // arbitrarily set it to "git". - if creds.Username == "" { - creds.Username = "git" - } - // Augment the URL with user/pass information. - credentialURL.User = url.UserPassword(creds.Username, creds.Password) - // Write the augmented URL to the location used by the "stored" credential - // helper. - credentialsPath := filepath.Join(r.homeDir, ".git-credentials") - if err := os.WriteFile( - credentialsPath, - []byte(credentialURL.String()), - 0600, - ); err != nil { - return fmt.Errorf("error writing credentials to %q: %w", credentialsPath, err) - } - return nil -} - -func (r *repo) buildCommand(command string, arg ...string) *exec.Cmd { - cmd := exec.Command(command, arg...) - cmd.Env = append(cmd.Env, os.Environ()...) - cmd.Env = append(cmd.Env, fmt.Sprintf("HOME=%s", r.homeDir)) - cmd.Dir = r.dir - return cmd -} - -func (r *repo) buildGitCommand(arg ...string) *exec.Cmd { - cmd := r.buildCommand("git", arg...) - cmd.Env = append(cmd.Env, fmt.Sprintf("GIT_SSH_COMMAND=ssh -F %s/.ssh/config", r.homeDir)) - if r.insecureSkipTLSVerify { - cmd.Env = append(cmd.Env, "GIT_SSL_NO_VERIFY=true") - } - return cmd -} diff --git a/internal/controller/git/repo.go b/internal/controller/git/repo.go new file mode 100644 index 000000000..c3837b619 --- /dev/null +++ b/internal/controller/git/repo.go @@ -0,0 +1,175 @@ +package git + +import ( + "fmt" + "os" + "path/filepath" + + libExec "github.com/akuity/kargo/internal/exec" +) + +// Repo is an interface for interacting with a Git repository with a single +// working tree. +type Repo interface { + // Close cleans up file system resources used by this repository. This should + // always be called before a repository goes out of scope. + Close() error + // Dir returns an absolute path to the repository. + Dir() string + // HomeDir returns an absolute path to the home directory of the system user + // who has cloned this repo. + HomeDir() string + // URL returns the remote URL of the repository. + URL() string + WorkTree +} + +// repo is an implementation of the Repo interface for interacting with a Git +// repository. +type repo struct { + *baseRepo + *workTree +} + +// CloneOptions represents options for cloning a Git repository with a single +// working tree. +type CloneOptions struct { + // BaseDir is an existing directory within which all other directories created + // and managed by the Repo implementation will be created. If not specified, + // the operating system's temporary directory will be used. Overriding that + // default is useful under certain circumstances. + BaseDir string + // Branch is the name of the branch to clone. If not specified, the default + // branch will be cloned. This option is ignored if Bare is true. + Branch string + // Depth is the number of commits to fetch from the remote repository. If + // zero, all commits will be fetched. This option is ignored if Bare is true. + Depth uint + // Filter allows for partially cloning the repository by specifying a + // filter. When a filter is specified, the server will only send a + // subset of reachable objects according to a given object filter. + // + // For more information, see: + // - https://git-scm.com/docs/git-clone#Documentation/git-clone.txt-code--filtercodeemltfilter-specgtem + // - https://git-scm.com/docs/git-rev-list#Documentation/git-rev-list.txt---filterltfilter-specgt + // - https://github.blog/2020-12-21-get-up-to-speed-with-partial-clone-and-shallow-clone/ + // - https://docs.gitlab.com/ee/topics/git/partial_clone.html + Filter string + // InsecureSkipTLSVerify specifies whether certificate verification errors + // should be ignored when cloning the repository. The setting will be + // remembered for subsequent interactions with the remote repository. + InsecureSkipTLSVerify bool + // SingleBranch indicates whether the clone should be a single-branch clone. + // This option is ignored if Bare is true. + SingleBranch bool +} + +// Clone produces a local clone of the remote git repository at the specified +// URL and returns an implementation of the Repo interface that is stateful and +// NOT suitable for use across multiple goroutines. This function will also +// perform any setup that is required for successfully authenticating to the +// remote repository. +func Clone( + repoURL string, + clientOpts *ClientOptions, + cloneOpts *CloneOptions, +) (Repo, error) { + if clientOpts == nil { + clientOpts = &ClientOptions{} + } + if cloneOpts == nil { + cloneOpts = &CloneOptions{} + } + homeDir, err := os.MkdirTemp(cloneOpts.BaseDir, "repo-") + if err != nil { + return nil, + fmt.Errorf("error creating home directory for repo %q: %w", repoURL, err) + } + if homeDir, err = filepath.EvalSymlinks(homeDir); err != nil { + return nil, + fmt.Errorf("error resolving symlinks in path %s: %w", homeDir, err) + } + baseRepo := &baseRepo{ + creds: clientOpts.Credentials, + dir: filepath.Join(homeDir, "repo"), + homeDir: homeDir, + insecureSkipTLSVerify: cloneOpts.InsecureSkipTLSVerify, + url: repoURL, + } + r := &repo{ + baseRepo: baseRepo, + workTree: &workTree{ + baseRepo: baseRepo, + }, + } + if err = r.setupClient(clientOpts); err != nil { + return nil, err + } + if err = r.clone(cloneOpts); err != nil { + return nil, err + } + if err = r.saveDirs(); err != nil { + return nil, err + } + return r, nil +} + +func (r *repo) clone(opts *CloneOptions) error { + if opts == nil { + opts = &CloneOptions{} + } + args := []string{"clone", "--no-tags"} + if opts.Branch != "" { + args = append(args, "--branch", opts.Branch) + } + if opts.SingleBranch { + args = append(args, "--single-branch") + } + if opts.Depth > 0 { + args = append(args, "--depth", fmt.Sprint(opts.Depth)) + } + args = append(args, r.url, r.dir) + cmd := r.buildGitCommand(args...) + cmd.Dir = r.homeDir // Override the cmd.Dir that's set by r.buildGitCommand() + if _, err := libExec.Exec(cmd); err != nil { + return fmt.Errorf("error cloning repo %q into %q: %w", r.url, r.dir, err) + } + return nil +} + +type LoadRepoOptions struct { + Credentials *RepoCredentials + InsecureSkipTLSVerify bool +} + +func LoadRepo(path string, opts *LoadRepoOptions) (Repo, error) { + if opts == nil { + opts = &LoadRepoOptions{} + } + baseRepo := &baseRepo{ + creds: opts.Credentials, + dir: path, + insecureSkipTLSVerify: opts.InsecureSkipTLSVerify, + } + r := &repo{ + baseRepo: baseRepo, + workTree: &workTree{ + baseRepo: baseRepo, + }, + } + if err := r.loadHomeDir(); err != nil { + return nil, fmt.Errorf("error reading repo home dir from config: %w", err) + } + if err := r.loadURL(); err != nil { + return nil, + fmt.Errorf(`error reading URL of remote "origin" from config: %w`, err) + } + if err := r.setupAuth(); err != nil { + return nil, fmt.Errorf("error configuring the credentials: %w", err) + } + return r, nil +} + +func (r *repo) Close() error { + return os.RemoveAll(r.homeDir) +} diff --git a/internal/controller/git/repo_test.go b/internal/controller/git/repo_test.go new file mode 100644 index 000000000..8612e1215 --- /dev/null +++ b/internal/controller/git/repo_test.go @@ -0,0 +1,206 @@ +package git + +import ( + "fmt" + "net/http/httptest" + "net/url" + "os" + "testing" + + "github.com/google/uuid" + "github.com/sosedoff/gitkit" + "github.com/stretchr/testify/require" + + "github.com/akuity/kargo/internal/types" +) + +func TestRepo(t *testing.T) { + testRepoCreds := RepoCredentials{ + Username: "fake-username", + Password: "fake-password", + } + + // This will be something to opt into because on some OSes, this will lead + // to keychain-related prompts. + var useAuth bool + if useAuthStr := os.Getenv("TEST_GIT_CLIENT_WITH_AUTH"); useAuthStr != "" { + useAuth = types.MustParseBool(useAuthStr) + } + service := gitkit.New( + gitkit.Config{ + Dir: t.TempDir(), + AutoCreate: true, + Auth: useAuth, + }, + ) + require.NoError(t, service.Setup()) + service.AuthFunc = + func(cred gitkit.Credential, _ *gitkit.Request) (bool, error) { + return cred.Username == testRepoCreds.Username && + cred.Password == testRepoCreds.Password, nil + } + server := httptest.NewServer(service) + defer server.Close() + + testRepoURL := fmt.Sprintf("%s/test.git", server.URL) + + rep, err := Clone( + testRepoURL, + &ClientOptions{ + Credentials: &testRepoCreds, + }, + nil, + ) + require.NoError(t, err) + require.NotNil(t, rep) + defer rep.Close() + r, ok := rep.(*repo) + require.True(t, ok) + + t.Run("can clone", func(t *testing.T) { + var repoURL *url.URL + repoURL, err = url.Parse(r.url) + require.NoError(t, err) + repoURL.User = nil + require.Equal(t, testRepoURL, repoURL.String()) + require.NotEmpty(t, r.homeDir) + var fi os.FileInfo + fi, err = os.Stat(r.homeDir) + require.NoError(t, err) + require.True(t, fi.IsDir()) + require.NotEmpty(t, r.dir) + fi, err = os.Stat(r.dir) + require.NoError(t, err) + require.True(t, fi.IsDir()) + }) + + t.Run("can get the repo url", func(t *testing.T) { + require.Equal(t, r.url, r.URL()) + }) + + t.Run("can get the home dir", func(t *testing.T) { + require.Equal(t, r.homeDir, r.HomeDir()) + }) + + t.Run("can get the working tree dir", func(t *testing.T) { + require.Equal(t, r.dir, r.Dir()) + }) + + t.Run("can check for diffs -- negative result", func(t *testing.T) { + var hasDiffs bool + hasDiffs, err = rep.HasDiffs() + require.NoError(t, err) + require.False(t, hasDiffs) + }) + + err = os.WriteFile(fmt.Sprintf("%s/%s", rep.Dir(), "test.txt"), []byte("foo"), 0600) + require.NoError(t, err) + + t.Run("can check for diffs -- positive result", func(t *testing.T) { + var hasDiffs bool + hasDiffs, err = rep.HasDiffs() + require.NoError(t, err) + require.True(t, hasDiffs) + }) + + testCommitMessage := fmt.Sprintf("test commit %s", uuid.NewString()) + err = rep.AddAllAndCommit(testCommitMessage) + require.NoError(t, err) + + t.Run("can commit", func(t *testing.T) { + require.NoError(t, err) + }) + + lastCommitID, err := rep.LastCommitID() + require.NoError(t, err) + + t.Run("can get last commit id", func(t *testing.T) { + require.NoError(t, err) + require.NotEmpty(t, lastCommitID) + }) + + t.Run("can get commit message by id", func(t *testing.T) { + var msg string + msg, err = rep.CommitMessage(lastCommitID) + require.NoError(t, err) + require.Equal(t, testCommitMessage, msg) + }) + + t.Run("can get diff paths", func(t *testing.T) { + var paths []string + paths, err = rep.GetDiffPathsForCommitID(lastCommitID) + require.NoError(t, err) + require.Len(t, paths, 1) + }) + + t.Run("can check if remote branch exists -- negative result", func(t *testing.T) { + var exists bool + exists, err = rep.RemoteBranchExists("main") // The remote repo is empty! + require.NoError(t, err) + require.False(t, exists) + }) + + err = rep.Push(false) + require.NoError(t, err) + + t.Run("can push", func(t *testing.T) { + require.NoError(t, err) + }) + + t.Run("can check if remote branch exists -- positive result", func(t *testing.T) { + var exists bool + // "master" is still the default branch name for a new repository unless + // you configure it otherwise. + exists, err = rep.RemoteBranchExists("master") + require.NoError(t, err) + require.True(t, exists) + }) + + testBranch := fmt.Sprintf("test-branch-%s", uuid.NewString()) + err = rep.CreateChildBranch(testBranch) + require.NoError(t, err) + + t.Run("can create a child branch", func(t *testing.T) { + require.NoError(t, err) + }) + + err = os.WriteFile(fmt.Sprintf("%s/%s", rep.Dir(), "test.txt"), []byte("bar"), 0600) + require.NoError(t, err) + + t.Run("can hard reset", func(t *testing.T) { + var hasDiffs bool + hasDiffs, err = rep.HasDiffs() + require.NoError(t, err) + require.True(t, hasDiffs) + err = rep.ResetHard() + require.NoError(t, err) + hasDiffs, err = rep.HasDiffs() + require.NoError(t, err) + require.False(t, hasDiffs) + }) + + t.Run("can create an orphaned branch", func(t *testing.T) { + testBranch := fmt.Sprintf("test-branch-%s", uuid.NewString()) + err = rep.CreateOrphanedBranch(testBranch) + require.NoError(t, err) + }) + + t.Run("can load an existing repo", func(t *testing.T) { + existingRepo, err := LoadRepo( + rep.Dir(), + &LoadRepoOptions{ + Credentials: &testRepoCreds, + }, + ) + require.NoError(t, err) + require.Equal(t, rep, existingRepo) + }) + + t.Run("can close repo", func(t *testing.T) { + require.NoError(t, rep.Close()) + _, err := os.Stat(r.HomeDir()) + require.Error(t, err) + require.True(t, os.IsNotExist(err)) + }) + +} diff --git a/internal/controller/git/work_tree.go b/internal/controller/git/work_tree.go new file mode 100644 index 000000000..9866ce5c8 --- /dev/null +++ b/internal/controller/git/work_tree.go @@ -0,0 +1,510 @@ +package git + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "os" + "strings" + "time" + + libExec "github.com/akuity/kargo/internal/exec" +) + +// WorkTree is an interface for interacting with any working tree of a Git +// repository. +type WorkTree interface { + // AddAll stages pending changes for commit. + AddAll() error + // AddAllAndCommit is a convenience function that stages pending changes for + // commit to the current branch and then commits them using the provided + // commit message. + AddAllAndCommit(message string) error + // Clean cleans the working tree. + Clean() error + // Close cleans up file system resources used by this working tree. This + // should always be called before a WorkTree goes out of scope. + Close() error + // Checkout checks out the specified branch. + Checkout(branch string) error + // Commit commits staged changes to the current branch. + Commit(message string, opts *CommitOptions) error + // CreateChildBranch creates a new branch that is a child of the current + // branch. + CreateChildBranch(branch string) error + // CreateOrphanedBranch creates a new branch that shares no commit history + // with any other branch. + CreateOrphanedBranch(branch string) error + // CurrentBranch returns the current branch + CurrentBranch() (string, error) + // DeleteBranch deletes the specified branch + DeleteBranch(branch string) error + // Dir returns an absolute path to the working tree. + Dir() string + // HasDiffs returns a bool indicating whether the working tree currently + // contains any differences from what's already at the head of the current + // branch. + HasDiffs() (bool, error) + // HomeDir returns an absolute path to the home directory of the system user + // who cloned the repo associated with this working tree. + HomeDir() string + // GetDiffPathsForCommitID returns a string slice indicating the paths, + // relative to the root of the repository, of any files that are new or + // modified in the commit with the given ID. + GetDiffPathsForCommitID(commitID string) ([]string, error) + // IsAncestor returns true if parent branch is an ancestor of child + IsAncestor(parent string, child string) (bool, error) + // LastCommitID returns the ID (sha) of the most recent commit to the current + // branch. + LastCommitID() (string, error) + // ListTags returns a slice of tags in the repository with metadata such as + // commit ID, creator date, and subject. + ListTags() ([]TagMetadata, error) + // ListCommits returns a slice of commits in the current branch with + // metadata such as commit ID, commit date, and subject. + ListCommits(limit, skip uint) ([]CommitMetadata, error) + // CommitMessage returns the text of the most recent commit message associated + // with the specified commit ID. + CommitMessage(id string) (string, error) + // Push pushes from the current branch to a remote branch by the same name. + Push(force bool) error + // RefsHaveDiffs returns whether there is a diff between two commits/branches + RefsHaveDiffs(commit1 string, commit2 string) (bool, error) + // RemoteBranchExists returns a bool indicating if the specified branch exists + // in the remote repository. + RemoteBranchExists(branch string) (bool, error) + // ResetHard performs a hard reset on the working tree. + ResetHard() error + // URL returns the remote URL of the repository. + URL() string +} + +// workTree is an implementation of the WorkTree interface for interacting with +// any working tree of a Git repository. +type workTree struct { + *baseRepo + bareRepo *bareRepo +} + +type LoadWorkTreeOptions struct { + Credentials *RepoCredentials + InsecureSkipTLSVerify bool +} + +func LoadWorkTree(path string, opts *LoadWorkTreeOptions) (WorkTree, error) { + if opts == nil { + opts = &LoadWorkTreeOptions{} + } + w := &workTree{ + baseRepo: &baseRepo{ + creds: opts.Credentials, + dir: path, + insecureSkipTLSVerify: opts.InsecureSkipTLSVerify, + }, + } + res, err := libExec.Exec(w.buildGitCommand( + "config", + "kargo.repoDir", + )) + if err != nil { + return nil, fmt.Errorf("error reading repo dir from config: %w", err) + } + repoPath := strings.TrimSpace(string(res)) + if err = w.loadHomeDir(); err != nil { + return nil, fmt.Errorf("error reading repo home dir from config: %w", err) + } + if err = w.loadURL(); err != nil { + return nil, + fmt.Errorf(`error reading URL of remote "origin" from config: %w`, err) + } + if err = w.setupAuth(); err != nil { + return nil, fmt.Errorf("error configuring the credentials: %w", err) + } + br, err := LoadBareRepo(repoPath, &LoadBareRepoOptions{ + Credentials: opts.Credentials, + InsecureSkipTLSVerify: opts.InsecureSkipTLSVerify, + }) + if err != nil { + return nil, err + } + w.bareRepo = br.(*bareRepo) // nolint: forcetypeassert + return w, nil +} + +func (w *workTree) AddAll() error { + if _, err := libExec.Exec(w.buildGitCommand("add", ".")); err != nil { + return fmt.Errorf("error staging changes for commit: %w", err) + } + return nil +} + +func (w *workTree) AddAllAndCommit(message string) error { + if err := w.AddAll(); err != nil { + return err + } + return w.Commit(message, nil) +} + +func (w *workTree) Clean() error { + if _, err := libExec.Exec(w.buildGitCommand("clean", "-fd")); err != nil { + return fmt.Errorf("error cleaning worktree: %w", err) + } + return nil +} + +func (w *workTree) Close() error { + if w.bareRepo != nil { + return w.bareRepo.RemoveWorkTree(w.dir) + } + if err := os.RemoveAll(w.dir); err != nil { + return fmt.Errorf("error removing working tree at %q: %w", w.dir, err) + } + return nil +} + +func (w *workTree) Checkout(branch string) error { + if _, err := libExec.Exec(w.buildGitCommand( + "checkout", + branch, + // The next line makes it crystal clear to git that we're checking out + // a branch. We need to do this because branch names can often resemble + // paths within the repo. + "--", + )); err != nil { + return fmt.Errorf("error checking out branch %q from repo %q: %w", branch, w.url, err) + } + return nil +} + +// CommitOptions represents options for committing changes to a git repository. +type CommitOptions struct { + // AllowEmpty indicates whether an empty commit should be allowed. + AllowEmpty bool +} + +func (w *workTree) Commit(message string, opts *CommitOptions) error { + if opts == nil { + opts = &CommitOptions{} + } + cmdTokens := []string{"commit", "-m", message} + if opts.AllowEmpty { + cmdTokens = append(cmdTokens, "--allow-empty") + } + + if _, err := libExec.Exec(w.buildGitCommand(cmdTokens...)); err != nil { + return fmt.Errorf("error committing changes: %w", err) + } + return nil +} + +func (w *workTree) CommitMessage(id string) (string, error) { + msgBytes, err := libExec.Exec( + w.buildGitCommand("log", "-n", "1", "--pretty=format:%s", id), + ) + if err != nil { + return "", fmt.Errorf("error obtaining commit message for commit %q: %w", id, err) + } + return string(msgBytes), nil +} + +func (w *workTree) CreateChildBranch(branch string) error { + if _, err := libExec.Exec(w.buildGitCommand( + "checkout", + "-b", + branch, + // The next line makes it crystal clear to git that we're checking out + // a branch. We need to do this because branch names can often resemble + // paths within the repo. + "--", + )); err != nil { + return fmt.Errorf("error creating new branch %q for repo %q: %w", branch, w.url, err) + } + return nil +} + +func (w *workTree) CreateOrphanedBranch(branch string) error { + if _, err := libExec.Exec(w.buildGitCommand( + "switch", + "--orphan", + branch, + "--discard-changes", + )); err != nil { + return fmt.Errorf("error creating orphaned branch %q for repo %q: %w", branch, w.url, err) + } + return w.Clean() +} + +func (w *workTree) CurrentBranch() (string, error) { + res, err := libExec.Exec(w.buildGitCommand("branch", "--show-current")) + if err != nil { + return "", fmt.Errorf("error checking current branch for repo %q: %w", w.url, err) + } + return string(res), nil +} + +func (w *workTree) DeleteBranch(branch string) error { + if _, err := libExec.Exec(w.buildGitCommand( + "branch", + "--delete", + "--force", + branch, + )); err != nil { + return fmt.Errorf("error deleting branch %q for repo %q: %w", branch, w.url, err) + } + return nil +} + +func (w *workTree) GetDiffPathsForCommitID(commitID string) ([]string, error) { + resBytes, err := libExec.Exec(w.buildGitCommand("show", "--pretty=", "--name-only", commitID)) + if err != nil { + return nil, fmt.Errorf("error getting diff paths for commit %q: %w", commitID, err) + } + var paths []string + scanner := bufio.NewScanner(bytes.NewReader(resBytes)) + scanner.Split(bufio.ScanLines) + for scanner.Scan() { + paths = append( + paths, + scanner.Text(), + ) + } + return paths, nil +} + +func (w *workTree) HasDiffs() (bool, error) { + resBytes, err := libExec.Exec(w.buildGitCommand("status", "-s")) + if err != nil { + return false, fmt.Errorf("error checking status of branch: %w", err) + } + return len(resBytes) > 0, nil +} + +func (w *workTree) IsAncestor(parent string, child string) (bool, error) { + _, err := libExec.Exec(w.buildGitCommand("merge-base", "--is-ancestor", parent, child)) + if err == nil { + return true, nil + } + var execErr *libExec.ExitError + if errors.As(err, &execErr) { + if execErr.ExitCode == 1 { + return false, nil + } + } + return false, fmt.Errorf("error testing ancestry of branches %q, %q: %w", parent, child, err) +} + +func (w *workTree) LastCommitID() (string, error) { + shaBytes, err := libExec.Exec(w.buildGitCommand("rev-parse", "HEAD")) + if err != nil { + return "", fmt.Errorf("error obtaining ID of last commit: %w", err) + } + return strings.TrimSpace(string(shaBytes)), nil +} + +type CommitMetadata struct { + // CommitID is the ID (sha) of the commit. + ID string + // CommitDate is the date of the commit. + CommitDate time.Time + // Author is the author of the commit, in the format "Name ". + Author string + // Committer is the person who committed the commit, in the format + // "Name ". + Committer string + // Subject is the subject (first line) of the commit message. + Subject string +} + +func (w *workTree) ListCommits(limit, skip uint) ([]CommitMetadata, error) { + args := []string{ + "log", + // This format is designed to output the following fields, separated by + // tabs (%x09): + // + // - commit ID + // - commit date + // - author name and email + // - committer name and email + // - subject + "--pretty=format:%H%x09%ci%x09%an <%ae>%x09%cn <%ce>%x09%s", + } + if limit > 0 { + args = append(args, fmt.Sprintf("--max-count=%d", limit)) + } + if skip > 0 { + args = append(args, fmt.Sprintf("--skip=%d", skip)) + } + + commitsBytes, err := libExec.Exec(w.buildGitCommand(args...)) + if err != nil { + return nil, fmt.Errorf("error listing commits for repo %q: %w", w.url, err) + } + + var commits []CommitMetadata + scanner := bufio.NewScanner(bytes.NewReader(commitsBytes)) + for scanner.Scan() { + line := scanner.Bytes() + parts := bytes.SplitN(scanner.Bytes(), []byte("\t"), 5) + if len(parts) != 5 { + return nil, fmt.Errorf("unexpected number of fields: %q", line) + } + + commitDate, err := time.Parse("2006-01-02 15:04:05 -0700", string(parts[1])) + if err != nil { + return nil, fmt.Errorf("error parsing commit date %q: %w", parts[1], err) + } + + commits = append(commits, CommitMetadata{ + ID: string(parts[0]), + CommitDate: commitDate, + Author: string(parts[2]), + Committer: string(parts[3]), + Subject: string(parts[4]), + }) + } + + return commits, nil +} + +// TagMetadata represents metadata associated with a Git tag. +type TagMetadata struct { + // Tag is the name of the tag. + Tag string + // CommitID is the ID (sha) of the commit associated with the tag. + CommitID string + // CreatorDate is the creation date of an annotated tag, or the commit date + // of a lightweight tag. + CreatorDate time.Time + // Author is the author of the commit message associated with the tag, in + // the format "Name ". + Author string + // Committer is the person who committed the commit associated with the tag, + // in the format "Name ". + Committer string + // Subject is the subject (first line) of the commit message associated + // with the tag. + Subject string +} + +func (w *workTree) ListTags() ([]TagMetadata, error) { + if _, err := libExec.Exec(w.buildGitCommand("fetch", "origin", "--tags")); err != nil { + return nil, fmt.Errorf("error fetching tags from repo %q: %w", w.url, err) + } + + // These formats are quite complex, so we break them down into smaller + // pieces for readability. + // + // They are designed to output the following fields, separated by `|*|`: + // - tag name + // - commit ID + // - subject + // - author name and email + // - committer name and email + // - creator date + // + // The `if`/`then`/`else` logic is used to ensure that we get the commit ID + // and subject of the tag, regardless of whether it's an annotated or + // lightweight tag. + // + // nolint: lll + const ( + formatAnnotatedTag = `%(refname:short)|*|%(*objectname)|*|%(*contents:subject)|*|%(*authorname) %(*authoremail)|*|%(*committername) %(*committeremail)|*|%(*creatordate:iso8601)` + formatLightweightTag = `%(refname:short)|*|%(objectname)|*|%(contents:subject)|*|%(authorname) %(authoremail)|*|%(committername) %(committeremail)|*|%(creatordate:iso8601)` + tagFormat = `%(if)%(*objectname)%(then)` + formatAnnotatedTag + `%(else)` + formatLightweightTag + `%(end)` + ) + + tagsBytes, err := libExec.Exec(w.buildGitCommand( + "for-each-ref", + "--sort=-creatordate", + "--format="+tagFormat, + "refs/tags", + )) + if err != nil { + return nil, fmt.Errorf("error listing tags for repo %q: %w", w.url, err) + } + + var tags []TagMetadata + scanner := bufio.NewScanner(bytes.NewReader(tagsBytes)) + for scanner.Scan() { + line := scanner.Bytes() + parts := bytes.SplitN(scanner.Bytes(), []byte("|*|"), 6) + if len(parts) != 6 { + return nil, fmt.Errorf("unexpected number of fields: %q", line) + } + + creatorDate, err := time.Parse("2006-01-02 15:04:05 -0700", string(parts[5])) + if err != nil { + return nil, fmt.Errorf("error parsing creator date %q: %w", parts[5], err) + } + + tags = append(tags, TagMetadata{ + Tag: string(parts[0]), + CommitID: string(parts[1]), + Subject: string(parts[2]), + Author: string(parts[3]), + Committer: string(parts[4]), + CreatorDate: creatorDate, + }) + } + + return tags, nil +} + +func (w *workTree) Push(force bool) error { + args := []string{"push", "origin", "HEAD"} + if force { + args = append(args, "--force") + } + if _, err := libExec.Exec(w.buildGitCommand(args...)); err != nil { + return fmt.Errorf("error pushing branch: %w", err) + } + return nil +} + +func (w *workTree) RefsHaveDiffs(commit1 string, commit2 string) (bool, error) { + // `git diff --quiet` returns 0 if no diff, 1 if diff, and non-zero/one for any other error + _, err := libExec.Exec(w.buildGitCommand( + "diff", "--quiet", fmt.Sprintf("%s..%s", commit1, commit2), "--")) + if err == nil { + return false, nil + } + var execErr *libExec.ExitError + if errors.As(err, &execErr) { + if execErr.ExitCode == 1 { + return true, nil + } + } + return false, fmt.Errorf("error diffing commits %s..%s: %w", commit1, commit2, err) +} + +func (w *workTree) RemoteBranchExists(branch string) (bool, error) { + _, err := libExec.Exec(w.buildGitCommand( + "ls-remote", + "--heads", + "--exit-code", // Return 2 if not found + w.url, + branch, + )) + var exitErr *libExec.ExitError + if errors.As(err, &exitErr) && exitErr.ExitCode == 2 { + // Branch does not exist + return false, nil + } + if err != nil { + return false, fmt.Errorf( + "error checking for existence of branch %q in remote repo %q: %w", + branch, + w.url, + err, + ) + } + return true, nil +} + +func (w *workTree) ResetHard() error { + if _, err := libExec.Exec(w.buildGitCommand("reset", "--hard")); err != nil { + return fmt.Errorf("error resetting branch working tree: %w", err) + } + return nil +} diff --git a/internal/controller/git/work_tree_test.go b/internal/controller/git/work_tree_test.go new file mode 100644 index 000000000..b7365eb34 --- /dev/null +++ b/internal/controller/git/work_tree_test.go @@ -0,0 +1,102 @@ +package git + +import ( + "fmt" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/google/uuid" + "github.com/sosedoff/gitkit" + "github.com/stretchr/testify/require" + + "github.com/akuity/kargo/internal/types" +) + +func TestWorkTree(t *testing.T) { + testRepoCreds := RepoCredentials{ + Username: "fake-username", + Password: "fake-password", + } + + // This will be something to opt into because on some OSes, this will lead + // to keychain-related prompts. + var useAuth bool + if useAuthStr := os.Getenv("TEST_GIT_CLIENT_WITH_AUTH"); useAuthStr != "" { + useAuth = types.MustParseBool(useAuthStr) + } + service := gitkit.New( + gitkit.Config{ + Dir: t.TempDir(), + AutoCreate: true, + Auth: useAuth, + }, + ) + require.NoError(t, service.Setup()) + service.AuthFunc = + func(cred gitkit.Credential, _ *gitkit.Request) (bool, error) { + return cred.Username == testRepoCreds.Username && + cred.Password == testRepoCreds.Password, nil + } + server := httptest.NewServer(service) + defer server.Close() + + testRepoURL := fmt.Sprintf("%s/test.git", server.URL) + + setupRep, err := Clone( + testRepoURL, + &ClientOptions{ + Credentials: &testRepoCreds, + }, + nil, + ) + require.NoError(t, err) + require.NotNil(t, setupRep) + defer setupRep.Close() + err = os.WriteFile(fmt.Sprintf("%s/%s", setupRep.Dir(), "test.txt"), []byte("foo"), 0600) + require.NoError(t, err) + err = setupRep.AddAllAndCommit(fmt.Sprintf("initial commit %s", uuid.NewString())) + require.NoError(t, err) + err = setupRep.Push(false) + require.NoError(t, err) + err = setupRep.Close() + require.NoError(t, err) + + rep, err := CloneBare( + testRepoURL, + &ClientOptions{ + Credentials: &testRepoCreds, + }, + nil, + ) + require.NoError(t, err) + require.NotNil(t, rep) + defer rep.Close() + + workingTreePath := filepath.Join(rep.HomeDir(), "working-tree") + // "master" is still the default branch name for a new repository unless + // you configure it otherwise. + workTree, err := rep.AddWorkTree(workingTreePath, "master") + require.NoError(t, err) + defer workTree.Close() + + t.Run("can load an existing working tree", func(t *testing.T) { + existingWorkTree, err := LoadWorkTree( + workTree.Dir(), + &LoadWorkTreeOptions{ + Credentials: &testRepoCreds, + }, + ) + require.NoError(t, err) + require.Equal(t, workTree, existingWorkTree) + }) + + t.Run("can close working tree", func(t *testing.T) { + require.NoError(t, workTree.Close()) + _, err := os.Stat(workTree.Dir()) + require.Error(t, err) + require.True(t, os.IsNotExist(err)) + }) + +} diff --git a/internal/controller/promotion/git.go b/internal/controller/promotion/git.go index 52fcc278d..1fb99cf7c 100644 --- a/internal/controller/promotion/git.go +++ b/internal/controller/promotion/git.go @@ -393,7 +393,7 @@ func (g *gitMechanism) gitCommit( newFreight, sourceCommitID, repo.HomeDir(), - repo.WorkingDir(), + repo.Dir(), repoCreds, ); err != nil { return "", err @@ -410,7 +410,7 @@ func (g *gitMechanism) gitCommit( } defer os.RemoveAll(tempDir) - if err = moveRepoContents(repo.WorkingDir(), tempDir); err != nil { + if err = moveRepoContents(repo.Dir(), tempDir); err != nil { return "", fmt.Errorf("error moving repository working tree to temporary location: %w", err) } @@ -446,11 +446,11 @@ func (g *gitMechanism) gitCommit( } } - if err = deleteRepoContents(repo.WorkingDir()); err != nil { + if err = deleteRepoContents(repo.Dir()); err != nil { return "", fmt.Errorf("error clearing contents from repository working tree: %w", err) } - if err = moveRepoContents(tempDir, repo.WorkingDir()); err != nil { + if err = moveRepoContents(tempDir, repo.Dir()); err != nil { return "", fmt.Errorf("error restoring repository working tree from temporary location: %w", err) } } diff --git a/internal/controller/promotion/pullrequest.go b/internal/controller/promotion/pullrequest.go index 89716cc0f..3bf0939fa 100644 --- a/internal/controller/promotion/pullrequest.go +++ b/internal/controller/promotion/pullrequest.go @@ -20,7 +20,10 @@ func pullRequestBranchName(project, stage string) string { // merging into the base branch. If the PR branch already exists, but not in a state that // we like (i.e. not a descendant of base), recreate it. func preparePullRequestBranch(repo git.Repo, prBranch string, base string) error { - origBranch := repo.CurrentBranch() + origBranch, err := repo.CurrentBranch() + if err != nil { + return err + } baseBranchExists, err := repo.RemoteBranchExists(base) if err != nil { return err