Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

initial ssh clone support #405

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ require (
github.com/go-logr/logr v1.4.1
github.com/google/go-cmp v0.6.0
github.com/google/go-github/v61 v61.0.0
github.com/kevinburke/ssh_config v1.2.1-0.20231022042432-1d09c0b50564
github.com/spf13/cobra v1.8.0
github.com/stretchr/testify v1.9.0
k8s.io/api v0.29.1
Expand Down Expand Up @@ -65,7 +66,6 @@ require (
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
Expand Down Expand Up @@ -119,4 +119,4 @@ require (
k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect
)
)
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,8 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/kevinburke/ssh_config v1.2.1-0.20231022042432-1d09c0b50564 h1:5RWThNvilNZUvijb0BuXorNxnYuHtKWb8eNZfTgZukU=
github.com/kevinburke/ssh_config v1.2.1-0.20231022042432-1d09c0b50564/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
Expand Down
144 changes: 138 additions & 6 deletions pkg/util/git_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
"errors"
"fmt"
"io"
"os"
"os/user"
"path/filepath"
"strings"
"sync"
Expand All @@ -17,7 +19,10 @@
"github.com/go-git/go-billy/v5/memfs"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/transport"
"github.com/go-git/go-git/v5/plumbing/transport/ssh"
"github.com/go-git/go-git/v5/storage/memory"
"github.com/kevinburke/ssh_config"
)

type RepoMap struct {
Expand Down Expand Up @@ -142,26 +147,57 @@
repo, err := git.PlainOpen(dir)
if err != nil {
if errors.Is(err, git.ErrRepositoryNotExists) {
ep, eErr := transport.NewEndpoint(remote.Url)
if eErr != nil {
return nil, nil, fmt.Errorf("reading endpoint %s: %w", remote.Url, eErr)
}

var auth transport.AuthMethod
if ep.Protocol == "ssh" {
a, aErr := ssh.DefaultAuthBuilder(ep.User)
if aErr != nil {
// go-git default auth relies on ssh agent. if not available, get from ~/.ssh/config.
if strings.Contains(aErr.Error(), "SSH agent requested but SSH_AUTH_SOCK not-specified") {
sshConfigPath, sErr := getSSHConfigAbsPath()
if sErr != nil {
return nil, nil, fmt.Errorf("getting ssh config file: %w", sErr)
}

au, sErr := getSSHKeyAuth(sshConfigPath, ep.Host, ep.User)
if sErr != nil {
return nil, nil, fmt.Errorf("ssh key auth: %w", sErr)
}

auth = au
} else {
return nil, nil, aErr
}
} else {
auth = a
}
}

cloneOptions := &git.CloneOptions{
URL: remote.Url,
Depth: depth,
ShallowSubmodules: true,
Tags: git.AllTags,
InsecureSkipTLS: insecureSkipTLS,
Auth: auth,
}
if remote.CloneSubmodules {
cloneOptions.RecurseSubmodules = git.DefaultSubmoduleRecursionDepth
}
repo, err = git.PlainCloneContext(ctx, dir, false, cloneOptions)
if err != nil {
repo, eErr = git.PlainCloneContext(ctx, dir, false, cloneOptions)
if eErr != nil {
if fallbackUrl != "" {
cloneOptions.URL = fallbackUrl
repo, err = git.PlainCloneContext(ctx, dir, false, cloneOptions)
if err != nil {
return nil, nil, fmt.Errorf("cloning repo with fall back url: %w", err)
repo, eErr = git.PlainCloneContext(ctx, dir, false, cloneOptions)
if eErr != nil {
return nil, nil, fmt.Errorf("cloning repo with fall back url: %w", eErr)
}
}
return nil, nil, fmt.Errorf("cloning repo: %w", err)
return nil, nil, fmt.Errorf("cloning repo: %w", eErr)
}
} else {
return nil, nil, fmt.Errorf("opening repo at %s %w", dir, err)
Expand Down Expand Up @@ -269,3 +305,99 @@

return nil
}

func getKeyfileAbsPath(relativePath string) (string, error) {
var absPath string
if strings.HasPrefix(relativePath, "~/") {
usr, err := user.Current()
if err != nil {
return "", err
}
keyFileAbs, err := filepath.Abs(filepath.Join(usr.HomeDir, relativePath[2:]))
if err != nil {
return "", err
}
absPath = keyFileAbs
} else {
keyFileAbs, err := filepath.Abs(relativePath)
if err != nil {
return "", err
}
absPath = keyFileAbs
}
return absPath, nil
}

func getSSHKeyAuth(configPath, host, user string) (transport.AuthMethod, error) {
f, err := os.Open(configPath)
if err != nil {
return nil, err
}

conf, err := ssh_config.Decode(f)
if err != nil {
return nil, err
}

keyFileRelativePath, err := conf.Get(host, "IdentityFile")
if err != nil {
return nil, err
}

// no key specified in config, find the default key
if keyFileRelativePath == "" {
homeDir, hErr := getHomeDir()
if hErr != nil {
return nil, hErr
}
// from `man ssh` on Mac OpenSSH_9.7p1, LibreSSL 3.3.6
keyFiles := []string{
"id_rsa",
"id_ecdsa",
"id_ecdsa_sk",
"id_ed25519",
"id_ed25519_sk",
"id_dsa",
}
for _, file := range keyFiles {
path := filepath.Join(homeDir, ".ssh", file)
if _, sErr := os.Stat(path); sErr == nil {
keyFileRelativePath = path
break
}
}
if keyFileRelativePath == "" {
return nil, fmt.Errorf("private key not speficied for %s. could not find default key", host)

Check failure on line 370 in pkg/util/git_repository.go

View workflow job for this annotation

GitHub Actions / Check for spelling errors

speficied ==> specified
}
}

absPath, err := getKeyfileAbsPath(keyFileRelativePath)
if err != nil {
return nil, err
}

auth, err := ssh.NewPublicKeysFromFile(user, absPath, "")
if err != nil {
return nil, err
}
return auth, nil
}

func getSSHConfigAbsPath() (string, error) {
homeDir, err := getHomeDir()
if err != nil {
return "", err
}
return filepath.Abs(filepath.Join(homeDir, ".ssh/config"))
}

func getHomeDir() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}
if homeDir == "" {
return "", fmt.Errorf("user does not have the home direcotry")

Check failure on line 400 in pkg/util/git_repository.go

View workflow job for this annotation

GitHub Actions / Check for spelling errors

direcotry ==> directory
}
return homeDir, nil
}
88 changes: 88 additions & 0 deletions pkg/util/git_repository_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ package util

import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
"os"
"path/filepath"
"strings"
Expand All @@ -11,6 +16,7 @@ import (
"github.com/go-git/go-billy/v5"
"github.com/go-git/go-billy/v5/memfs"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/transport/ssh"
"github.com/go-git/go-git/v5/storage/memory"
"github.com/stretchr/testify/assert"
)
Expand Down Expand Up @@ -114,3 +120,85 @@ func TestGetWorktreeYamlFiles(t *testing.T) {
assert.Equal(t, nil, err)
assert.Equal(t, 0, len(paths))
}

func TestGetKeyfileAbsPath(t *testing.T) {
homeDir, _ := getHomeDir()
cwd, _ := os.Getwd()
tests := []struct {
name string
input string
expected string
hasError bool
}{
{"Relative path", "testkey", filepath.Join(cwd, "testkey"), false},
{"Home directory", "~/testkey", filepath.Join(homeDir, "testkey"), false},
{"Absolute path", "/tmp/testkey", "/tmp/testkey", false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := getKeyfileAbsPath(tt.input)
if tt.hasError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expected, result)
}
})
}
}

func TestGetSSHKeyAuth(t *testing.T) {
// Create a temporary SSH config file
sshConfFile, err := os.CreateTemp("", "sshconfig")
assert.NoError(t, err)
defer os.Remove(sshConfFile.Name())

keyPath, err := createTestPrivateKey()
assert.NoError(t, err)
defer os.Remove(keyPath)

_, _ = sshConfFile.Write([]byte(fmt.Sprintf("Host testhost\nIdentityFile %s", keyPath)))
sshConfFile.Close()

auth, err := getSSHKeyAuth(sshConfFile.Name(), "testhost", "git")
assert.NoError(t, err)
assert.IsType(t, &ssh.PublicKeys{}, auth)

_, err = getSSHKeyAuth("/nonexistent/path", "testhost", "git")
assert.Error(t, err)

_, err = getSSHKeyAuth(sshConfFile.Name(), "not-in-config", "git")
assert.Error(t, err)
}

func TestGetSSHConfigAbsPath(t *testing.T) {
expected, err := filepath.Abs(filepath.Join(os.Getenv("HOME"), ".ssh/config"))
assert.NoError(t, err)

result, err := getSSHConfigAbsPath()
assert.NoError(t, err)
assert.True(t, filepath.IsAbs(result))
assert.Equal(t, expected, result)
}

func createTestPrivateKey() (string, error) {
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return "", err
}

privKeyPEM := &pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
}

keyfile, err := os.CreateTemp("", "key")
if err != nil {
return "", err
}
defer keyfile.Close()

pem.Encode(keyfile, privKeyPEM)
return keyfile.Name(), nil
}
Loading